| import { prisma } from './prisma'; |
| import { logger } from '../logger'; |
| import { redis } from '../lib/redis'; |
|
|
| const CACHE_TTL = 86400; |
| const META_GRAPH_VERSION = process.env.META_GRAPH_API_VERSION || 'v22.0'; |
|
|
| export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise<string | null> { |
| const cacheKey = `org:phone:${phoneNumberId}`; |
| |
| |
| try { |
| const cached = await redis.get(cacheKey); |
| if (cached) return cached; |
| } catch (err) { |
| logger.error({ err }, '[ORG-SERVICE] Redis error:'); |
| } |
|
|
| |
| const phoneRecord = await prisma.whatsAppPhoneNumber.findUnique({ |
| where: { id: phoneNumberId }, |
| select: { organizationId: true } |
| }); |
|
|
| if (phoneRecord) { |
| try { |
| await redis.set(cacheKey, phoneRecord.organizationId, 'EX', CACHE_TTL); |
| } catch (err) { |
| logger.error({ err }, '[ORG-SERVICE] Redis set error:'); |
| } |
| return phoneRecord.organizationId; |
| } |
|
|
| logger.warn(`[ORG-SERVICE] No organization found for phone_number_id: ${phoneNumberId} β rejecting`); |
| return null; |
| } |
|
|
| |
| |
| |
| export async function invalidateOrganizationCache(organizationId: string, phoneNumberId?: string) { |
| try { |
| const keys = [`org:config:${organizationId}`]; |
| if (phoneNumberId) { |
| keys.push(`org:phone:${phoneNumberId}`); |
| } |
| await redis.del(keys); |
| logger.info(`[ORG-SERVICE] Cache invalidated for Organization: ${organizationId}`); |
| } catch (err) { |
| logger.error({ err }, '[ORG-SERVICE] Cache invalidation failed:'); |
| } |
| } |
|
|
| import { config } from '../config'; |
| const ENCRYPTION_SECRET = config.ENCRYPTION_SECRET; |
|
|
| import { encrypt, decrypt } from '@repo/shared-types'; |
|
|
| export function encryptSecrets(data: any) { |
| if (data.systemUserToken && !data.systemUserToken.startsWith('enc:')) { |
| data.systemUserToken = encrypt(data.systemUserToken, ENCRYPTION_SECRET); |
| } |
| if (data.webhookSecret && !data.webhookSecret.startsWith('enc:')) { |
| data.webhookSecret = encrypt(data.webhookSecret, ENCRYPTION_SECRET); |
| } |
| if (data.openAiApiKey && !data.openAiApiKey.startsWith('enc:')) { |
| data.openAiApiKey = encrypt(data.openAiApiKey, ENCRYPTION_SECRET); |
| } |
| if (data.googleAiApiKey && !data.googleAiApiKey.startsWith('enc:')) { |
| data.googleAiApiKey = encrypt(data.googleAiApiKey, ENCRYPTION_SECRET); |
| } |
| return data; |
| } |
|
|
| export function decryptSecrets(org: any) { |
| return { |
| ...org, |
| ...(org.systemUserToken ? { systemUserToken: decrypt(org.systemUserToken, ENCRYPTION_SECRET) } : {}), |
| ...(org.webhookSecret ? { webhookSecret: decrypt(org.webhookSecret, ENCRYPTION_SECRET) } : {}), |
| ...(org.openAiApiKey ? { openAiApiKey: decrypt(org.openAiApiKey, ENCRYPTION_SECRET) } : {}), |
| ...(org.googleAiApiKey ? { googleAiApiKey: decrypt(org.googleAiApiKey, ENCRYPTION_SECRET) } : {}), |
| }; |
| } |
|
|
| |
|
|
| interface MetaApiError { message: string; type?: string; code?: number } |
|
|
| interface WabaStatusResponse { |
| account_review_status?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN'; |
| error?: MetaApiError; |
| } |
|
|
| interface BusinessVerificationResponse { |
| id?: string; |
| name?: string; |
| verification_status?: string; |
| error?: MetaApiError; |
| } |
|
|
| interface WabaBusinessEdgeResponse { |
| business?: { id: string; name: string; verification_status: string }; |
| error?: MetaApiError; |
| } |
|
|
| interface WabaOnBehalfResponse { |
| on_behalf_of_business_info?: { id: string; name?: string }; |
| error?: MetaApiError; |
| } |
|
|
| interface PhoneNumberTierResponse { |
| messaging_limit_tier?: string; |
| quality_rating?: 'GREEN' | 'YELLOW' | 'RED' | 'UNKNOWN'; |
| error?: MetaApiError; |
| } |
|
|
| |
|
|
| export interface MetaStatus { |
| configured: boolean; |
| wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN'; |
| businessId?: string; |
| businessName?: string; |
| businessVerified?: boolean; |
| messagingLimitTier?: string; |
| qualityRating?: 'GREEN' | 'YELLOW' | 'RED' | 'UNKNOWN'; |
| tokenIssuedAt?: string; |
| syncedAt: string; |
| error?: string; |
| } |
|
|
| export async function fetchMetaStatus(organizationId: string, forceRefresh = false): Promise<MetaStatus> { |
| const cacheKey = `meta:status:${organizationId}`; |
|
|
| if (!forceRefresh) { |
| try { |
| const cached = await redis.get(cacheKey); |
| if (cached) return JSON.parse(cached) as MetaStatus; |
| } catch (err) { |
| logger.error({ err }, '[META-STATUS] Redis get error'); |
| } |
| } |
|
|
| |
| |
| |
| const org = await prisma.organization.findUnique({ |
| where: { id: organizationId }, |
| include: { phoneNumbers: { take: 1 } } |
| }); |
| if (!org?.wabaId || !org?.systemUserToken) { |
| return { configured: false, syncedAt: new Date().toISOString() }; |
| } |
|
|
| const decrypted = decryptSecrets({ ...org }); |
|
|
| const headers = { Authorization: `Bearer ${decrypted.systemUserToken}` }; |
|
|
| |
| let wabaStatus: MetaStatus['wabaStatus'] = 'UNKNOWN'; |
| let wabaError: string | undefined; |
| try { |
| const wabaRes = await fetch( |
| `https://graph.facebook.com/${META_GRAPH_VERSION}/${org.wabaId}?fields=account_review_status`, |
| { headers } |
| ); |
| const wabaData = await wabaRes.json() as WabaStatusResponse; |
| if (wabaData.error) { |
| wabaError = wabaData.error.message; |
| logger.warn({ err: wabaData.error, organizationId }, '[META-STATUS] WABA status error'); |
| } else { |
| wabaStatus = wabaData.account_review_status ?? 'UNKNOWN'; |
| } |
| } catch (err) { |
| wabaError = 'Network error'; |
| logger.error({ err, organizationId }, '[META-STATUS] WABA fetch failed'); |
| } |
|
|
| |
| let businessId: string | undefined; |
| let businessName: string | undefined; |
| let businessVerified: boolean | undefined; |
|
|
| |
| if (org.metaBusinessId) { |
| try { |
| const vRes = await fetch( |
| `https://graph.facebook.com/${META_GRAPH_VERSION}/${org.metaBusinessId}?fields=name,verification_status`, |
| { headers } |
| ); |
| const vData = await vRes.json() as BusinessVerificationResponse; |
| if (!vData.error) { |
| businessId = org.metaBusinessId; |
| businessName = vData.name; |
| businessVerified = vData.verification_status === 'verified'; |
| } |
| } catch { } |
| } |
|
|
| |
| if (businessId === undefined) { |
| try { |
| const bizRes = await fetch( |
| `https://graph.facebook.com/${META_GRAPH_VERSION}/${org.wabaId}?fields=business{id,name,verification_status}`, |
| { headers } |
| ); |
| const bizData = await bizRes.json() as WabaBusinessEdgeResponse; |
| if (!bizData.error && bizData.business) { |
| businessId = bizData.business.id; |
| businessName = bizData.business.name; |
| businessVerified = bizData.business.verification_status === 'verified'; |
| } |
| } catch { } |
| } |
|
|
| |
| if (businessId === undefined) { |
| try { |
| const obRes = await fetch( |
| `https://graph.facebook.com/${META_GRAPH_VERSION}/${org.wabaId}?fields=on_behalf_of_business_info`, |
| { headers } |
| ); |
| const obData = await obRes.json() as WabaOnBehalfResponse; |
| if (!obData.error && obData.on_behalf_of_business_info?.id) { |
| businessId = obData.on_behalf_of_business_info.id; |
| businessName = obData.on_behalf_of_business_info.name; |
| const vRes = await fetch( |
| `https://graph.facebook.com/${META_GRAPH_VERSION}/${businessId}?fields=name,verification_status`, |
| { headers } |
| ); |
| const vData = await vRes.json() as BusinessVerificationResponse; |
| if (!vData.error) { |
| businessVerified = vData.verification_status === 'verified'; |
| if (vData.name) businessName = vData.name; |
| } |
| } |
| } catch { } |
| } |
|
|
| |
| let messagingLimitTier: string | undefined; |
| let qualityRating: MetaStatus['qualityRating'] | undefined; |
| try { |
| const phoneNumberId = org.phoneNumbers?.[0]?.id; |
| if (phoneNumberId) { |
| const phoneRes = await fetch( |
| `https://graph.facebook.com/${META_GRAPH_VERSION}/${phoneNumberId}?fields=messaging_limit_tier,quality_rating`, |
| { headers } |
| ); |
| const phoneData = await phoneRes.json() as PhoneNumberTierResponse; |
| if (!phoneData.error) { |
| messagingLimitTier = phoneData.messaging_limit_tier; |
| qualityRating = phoneData.quality_rating; |
| if (!messagingLimitTier) { |
| logger.warn({ organizationId, phoneNumberId, raw: phoneData }, '[META-STATUS] messaging_limit_tier absent β token may lack BUSINESS_MANAGEMENT permission'); |
| } |
| } else { |
| logger.warn({ err: phoneData.error, organizationId, phoneNumberId }, '[META-STATUS] Phone tier fetch error'); |
| } |
| } |
| } catch (err) { |
| logger.error({ err, organizationId }, '[META-STATUS] Phone tier fetch threw'); |
| } |
|
|
| const result: MetaStatus = { |
| configured: true, |
| wabaStatus, |
| businessId, |
| businessName, |
| businessVerified, |
| messagingLimitTier, |
| qualityRating, |
| tokenIssuedAt: org.systemUserTokenIssuedAt?.toISOString(), |
| syncedAt: new Date().toISOString(), |
| ...(wabaError && { error: wabaError }) |
| }; |
|
|
| const ttl = wabaError ? 300 : 3600; |
| await redis.set(cacheKey, JSON.stringify(result), 'EX', ttl).catch(err => |
| logger.error({ err }, '[META-STATUS] Redis set error') |
| ); |
| return result; |
| } |
|
|
| |
|
|
| |
| |
| |
| export async function getTenantSecrets(organizationId: string) { |
| const org = await prisma.organization.findUnique({ |
| where: { id: organizationId }, |
| select: { |
| systemUserToken: true, |
| webhookSecret: true, |
| openAiApiKey: true, |
| googleAiApiKey: true, |
| } |
| }); |
| |
| if (!org) return null; |
| return decryptSecrets(org); |
| } |
|
|