import { prisma } from './prisma'; import { logger } from '../logger'; import { redis } from '../lib/redis'; const CACHE_TTL = 86400; // 24 hours (routing is stable) const META_GRAPH_VERSION = process.env.META_GRAPH_API_VERSION || 'v22.0'; export async function getOrganizationByPhoneNumberId(phoneNumberId: string): Promise { const cacheKey = `org:phone:${phoneNumberId}`; // 1. Check Redis Cache try { const cached = await redis.get(cacheKey); if (cached) return cached; } catch (err) { logger.error({ err }, '[ORG-SERVICE] Redis error:'); } // 2. Lookup in DB 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; } /** * Invalidates all cache entries related to an organization */ 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) } : {}), }; } // ─── Meta Graph API response shapes ───────────────────────────────────────── 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; } // ─── Meta Business Status ───────────────────────────────────────────────────── export interface MetaStatus { configured: boolean; wabaStatus?: 'APPROVED' | 'PENDING' | 'REJECTED' | 'BANNED' | 'UNKNOWN'; businessId?: string; businessName?: string; businessVerified?: boolean; messagingLimitTier?: string; // e.g. TIER_1K, TIER_10K, TIER_100K, UNLIMITED qualityRating?: 'GREEN' | 'YELLOW' | 'RED' | 'UNKNOWN'; tokenIssuedAt?: string; // ISO date — to compute days until 60-day expiry syncedAt: string; error?: string; } export async function fetchMetaStatus(organizationId: string, forceRefresh = false): Promise { 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'); } } // Organization is excluded from tenant filtering — safe to query cross-org. // Include phoneNumbers here to avoid a separate WhatsAppPhoneNumber query // which IS tenant-filtered and would return null when called from another org's context. 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}` }; // Call 1 — WABA status (critical, must succeed) 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'); } // Call 2 — Business verification (optional, three attempts in priority order) let businessId: string | undefined; let businessName: string | undefined; let businessVerified: boolean | undefined; // Attempt A: stored metaBusinessId — most reliable, bypasses WABA edge permissions 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 { /* non-fatal */ } } // Attempt B: business edge on WABA (works on production WABAs with business_management scope) 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 { /* non-fatal */ } } // Attempt C: on_behalf_of_business_info fallback (broader token compatibility) 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 { /* non-fatal */ } } // Call 3 — Phone number messaging tier + quality rating (live from Meta) 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; } // ─── Tenant Secrets ────────────────────────────────────────────────────────── /** * Retrieves all secrets for a tenant, decrypted. */ 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); }