edtech / apps /api /src /services /organization.ts
CognxSafeTrack
fix: upgrade Meta Graph API from deprecated v18/v19 to v22.0
b25d16e
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);
}