Download apps/api/src/services/organization.ts from safetrack/edtech: direct link, hf CLI and curl.
- Browser
- Download file 11.9 kB
-
https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/api/src/services/organization.ts
- Command line
-
hf download hf://spaces/safetrack/edtech/apps/api/src/services/organization.ts
-
curl -L -o organization.ts https://huggingface.co/spaces/safetrack/edtech/resolve/main/apps/api/src/services/organization.ts
11.9 kB
| 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<string | null> { | |
| 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<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'); | |
| } | |
| } | |
| // 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); | |
| } | |