fix: security hardening, real AI costs, traceId propagation & audit logging
Browse files- S1: enforceOrgIsolation β organizationId always from JWT for non-SUPER_ADMIN (header ignored)
- S4: auditService.log() on ORG_MODE_CHANGED, ORG_PERSONALITY_UPDATED, ORG_BYOK_KEYS_UPDATED
- O1: traceId (UUID) generated at webhook, forwarded via x-trace-id header, injected into BullMQ job data, threaded through whatsapp-logic handler chain
- D3: /v1/analytics/usage now aggregates real UsageEvent.costUsd + tokens (tokensIn/Out) with per-feature breakdown instead of token estimates
- D4: BrandingDataSchema added to shared-types β brandingData strictly typed (logoUrl, primaryColor, orgName) instead of z.any()
- A6: InboundHandler fetches user.language in parallel with org token, passes it to Whisper
- A2: sendLessonDay() caches personalized lesson text in Redis 6h by (trackDayId, activity, language)
- P1: sendLessonDay() parallelized β user+trackDay batched in one include, userProgress fetched in parallel
- D2: ExerciseHandler Redis 30s dedup lock prevents double exercise submission
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- apps/api/src/routes/analytics.ts +29 -18
- apps/api/src/routes/billing.ts +7 -0
- apps/api/src/routes/organizations.ts +17 -0
- apps/api/src/routes/whatsapp.ts +4 -2
- apps/whatsapp-worker/src/handlers/InboundHandler.ts +3 -2
- apps/whatsapp-worker/src/handlers/types.ts +1 -0
- apps/whatsapp-worker/src/index.ts +7 -6
- apps/whatsapp-worker/src/services/whatsapp-logic.ts +7 -6
- packages/shared-types/src/organization.ts +9 -1
|
@@ -22,7 +22,9 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
|
|
| 22 |
inboundMessages,
|
| 23 |
outboundMessages,
|
| 24 |
totalUsers,
|
| 25 |
-
activeUsersLast24h
|
|
|
|
|
|
|
| 26 |
] = await Promise.all([
|
| 27 |
prisma.message.count({ where: { organizationId } }),
|
| 28 |
prisma.message.count({ where: { organizationId, direction: 'INBOUND' } }),
|
|
@@ -33,22 +35,23 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
|
|
| 33 |
organizationId,
|
| 34 |
lastActivityAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
|
| 35 |
}
|
| 36 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
]);
|
| 38 |
|
| 39 |
-
|
| 40 |
-
const
|
| 41 |
-
|
| 42 |
-
// Pricing per 1M tokens (input+output blended) β updated May 2026
|
| 43 |
-
const MODEL_PRICES_USD_PER_1M: Record<string, number> = {
|
| 44 |
-
'gpt-4o': 7.50,
|
| 45 |
-
'gpt-4o-mini': 0.30,
|
| 46 |
-
'gemini-2.0-flash': 0.15,
|
| 47 |
-
'gemini-1.5-pro': 3.50,
|
| 48 |
-
'claude-sonnet-4-6': 4.50,
|
| 49 |
-
};
|
| 50 |
-
const activeModel = process.env.DEFAULT_AI_MODEL || 'gpt-4o-mini';
|
| 51 |
-
const pricePerMillion = MODEL_PRICES_USD_PER_1M[activeModel] ?? 0.30;
|
| 52 |
|
| 53 |
return {
|
| 54 |
messages: {
|
|
@@ -61,9 +64,17 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
|
|
| 61 |
activeLast24h: activeUsersLast24h
|
| 62 |
},
|
| 63 |
costs: {
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
};
|
| 69 |
} catch (err) {
|
|
|
|
| 22 |
inboundMessages,
|
| 23 |
outboundMessages,
|
| 24 |
totalUsers,
|
| 25 |
+
activeUsersLast24h,
|
| 26 |
+
usageAgg,
|
| 27 |
+
usageByFeature,
|
| 28 |
] = await Promise.all([
|
| 29 |
prisma.message.count({ where: { organizationId } }),
|
| 30 |
prisma.message.count({ where: { organizationId, direction: 'INBOUND' } }),
|
|
|
|
| 35 |
organizationId,
|
| 36 |
lastActivityAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
|
| 37 |
}
|
| 38 |
+
}),
|
| 39 |
+
// Real costs from UsageEvent table
|
| 40 |
+
prisma.usageEvent.aggregate({
|
| 41 |
+
where: { organizationId },
|
| 42 |
+
_sum: { tokensIn: true, tokensOut: true, costUsd: true },
|
| 43 |
+
}),
|
| 44 |
+
prisma.usageEvent.groupBy({
|
| 45 |
+
by: ['feature'],
|
| 46 |
+
where: { organizationId },
|
| 47 |
+
_sum: { costUsd: true, tokensIn: true, tokensOut: true },
|
| 48 |
+
_count: { _all: true },
|
| 49 |
+
}),
|
| 50 |
]);
|
| 51 |
|
| 52 |
+
const realTokensIn = usageAgg._sum.tokensIn ?? 0;
|
| 53 |
+
const realTokensOut = usageAgg._sum.tokensOut ?? 0;
|
| 54 |
+
const realCostUsd = usageAgg._sum.costUsd ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
return {
|
| 57 |
messages: {
|
|
|
|
| 64 |
activeLast24h: activeUsersLast24h
|
| 65 |
},
|
| 66 |
costs: {
|
| 67 |
+
tokensIn: realTokensIn,
|
| 68 |
+
tokensOut: realTokensOut,
|
| 69 |
+
totalTokens: realTokensIn + realTokensOut,
|
| 70 |
+
totalUsd: realCostUsd,
|
| 71 |
+
byFeature: usageByFeature.map((f: any) => ({
|
| 72 |
+
feature: f.feature,
|
| 73 |
+
calls: f._count._all,
|
| 74 |
+
tokensIn: f._sum.tokensIn ?? 0,
|
| 75 |
+
tokensOut: f._sum.tokensOut ?? 0,
|
| 76 |
+
costUsd: f._sum.costUsd ?? 0,
|
| 77 |
+
})),
|
| 78 |
}
|
| 79 |
};
|
| 80 |
} catch (err) {
|
|
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
|
|
| 2 |
import { prisma } from '../services/prisma';
|
| 3 |
import OpenAI from 'openai';
|
| 4 |
import { creditWallet } from '../services/wallet';
|
|
|
|
| 5 |
|
| 6 |
const PLAN_LIMITS: Record<string, { aiCreditsLimit: number; label: string }> = {
|
| 7 |
STARTER: { aiCreditsLimit: 500, label: 'DΓ©marrage' },
|
|
@@ -343,6 +344,12 @@ Les templates sont des messages prΓ©-approuvΓ©s par Meta pour initier une conver
|
|
| 343 |
const allowed = ['EDTECH', 'CRM_MARKETING', 'AI_AGENT', 'CUSTOMER_SERVICE'];
|
| 344 |
if (!allowed.includes(mode)) return JSON.stringify({ error: `Invalid mode. Allowed: ${allowed.join(', ')}` });
|
| 345 |
await prisma.organization.update({ where: { id: organizationId }, data: { mode: mode as any } });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
return JSON.stringify({ success: true, newMode: mode, message: `Mode changed to ${mode}` });
|
| 347 |
}
|
| 348 |
|
|
|
|
| 2 |
import { prisma } from '../services/prisma';
|
| 3 |
import OpenAI from 'openai';
|
| 4 |
import { creditWallet } from '../services/wallet';
|
| 5 |
+
import { auditService } from '../services/audit';
|
| 6 |
|
| 7 |
const PLAN_LIMITS: Record<string, { aiCreditsLimit: number; label: string }> = {
|
| 8 |
STARTER: { aiCreditsLimit: 500, label: 'DΓ©marrage' },
|
|
|
|
| 344 |
const allowed = ['EDTECH', 'CRM_MARKETING', 'AI_AGENT', 'CUSTOMER_SERVICE'];
|
| 345 |
if (!allowed.includes(mode)) return JSON.stringify({ error: `Invalid mode. Allowed: ${allowed.join(', ')}` });
|
| 346 |
await prisma.organization.update({ where: { id: organizationId }, data: { mode: mode as any } });
|
| 347 |
+
auditService.log({
|
| 348 |
+
action: 'ORG_MODE_CHANGED',
|
| 349 |
+
actorId: (req as any).user?.id,
|
| 350 |
+
resourceId: organizationId,
|
| 351 |
+
details: { newMode: mode, callerRole },
|
| 352 |
+
});
|
| 353 |
return JSON.stringify({ success: true, newMode: mode, message: `Mode changed to ${mode}` });
|
| 354 |
}
|
| 355 |
|
|
@@ -12,6 +12,7 @@ import { createSubscriber, orgChannel } from '../lib/redis';
|
|
| 12 |
import { KBService } from '../services/kb-service';
|
| 13 |
import { CRMService } from '../services/crm-service';
|
| 14 |
import { OrganizationCreationSchema, createOrganizationWithAdmin } from '../services/organization-service';
|
|
|
|
| 15 |
|
| 16 |
type P<K extends string = 'id'> = { Params: Record<K, string> };
|
| 17 |
type Q<T extends Record<string, string | undefined>> = { Querystring: T };
|
|
@@ -100,6 +101,16 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 100 |
const data = encryptSecrets(body.data);
|
| 101 |
const org = await prisma.organization.update({ where: { id }, data });
|
| 102 |
await invalidateOrganizationCache(id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
return decryptSecrets(org);
|
| 104 |
} catch (err) {
|
| 105 |
logger.warn({ err, organizationId: id }, '[ORG] update failed');
|
|
@@ -198,6 +209,12 @@ export async function organizationRoutes(fastify: FastifyInstance) {
|
|
| 198 |
});
|
| 199 |
|
| 200 |
await invalidateOrganizationCache(id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
return updatedOrg;
|
| 202 |
} catch (err) {
|
| 203 |
logger.warn({ err, organizationId: id }, '[ORG] personality update failed');
|
|
|
|
| 12 |
import { KBService } from '../services/kb-service';
|
| 13 |
import { CRMService } from '../services/crm-service';
|
| 14 |
import { OrganizationCreationSchema, createOrganizationWithAdmin } from '../services/organization-service';
|
| 15 |
+
import { auditService } from '../services/audit';
|
| 16 |
|
| 17 |
type P<K extends string = 'id'> = { Params: Record<K, string> };
|
| 18 |
type Q<T extends Record<string, string | undefined>> = { Querystring: T };
|
|
|
|
| 101 |
const data = encryptSecrets(body.data);
|
| 102 |
const org = await prisma.organization.update({ where: { id }, data });
|
| 103 |
await invalidateOrganizationCache(id);
|
| 104 |
+
|
| 105 |
+
if (wantsOwnKeys) {
|
| 106 |
+
auditService.log({
|
| 107 |
+
action: 'ORG_BYOK_KEYS_UPDATED',
|
| 108 |
+
actorId: req.user?.id,
|
| 109 |
+
resourceId: id,
|
| 110 |
+
details: { updatedKeys: Object.keys(body.data).filter(k => k.includes('ApiKey')) },
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
return decryptSecrets(org);
|
| 115 |
} catch (err) {
|
| 116 |
logger.warn({ err, organizationId: id }, '[ORG] update failed');
|
|
|
|
| 209 |
});
|
| 210 |
|
| 211 |
await invalidateOrganizationCache(id);
|
| 212 |
+
auditService.log({
|
| 213 |
+
action: 'ORG_PERSONALITY_UPDATED',
|
| 214 |
+
actorId: req.user?.id,
|
| 215 |
+
resourceId: id,
|
| 216 |
+
details: { fields: Object.keys(body.data) },
|
| 217 |
+
});
|
| 218 |
return updatedOrg;
|
| 219 |
} catch (err) {
|
| 220 |
logger.warn({ err, organizationId: id }, '[ORG] personality update failed');
|
|
@@ -153,6 +153,7 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
|
|
| 153 |
// The bridge owns org resolution, WEBHOOK mode handling, and Redis queue ops.
|
| 154 |
// WORKER_BRIDGE_URL env var can override for separate-container deployments.
|
| 155 |
const workerBridgeUrl = process.env.WORKER_BRIDGE_URL || 'http://localhost:8082';
|
|
|
|
| 156 |
reply.code(200).send('EVENT_RECEIVED');
|
| 157 |
setImmediate(async () => {
|
| 158 |
try {
|
|
@@ -161,11 +162,12 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
|
|
| 161 |
headers: {
|
| 162 |
'Content-Type': 'application/json',
|
| 163 |
'Authorization': `Bearer ${process.env.ADMIN_API_KEY}`,
|
| 164 |
-
'x-organization-id': organizationId
|
|
|
|
| 165 |
},
|
| 166 |
body: JSON.stringify(request.body)
|
| 167 |
});
|
| 168 |
-
logger.info({ status: res.status, organizationId, phoneNumberId }, '[WHATSAPP-GATEWAY] Forwarded to worker bridge');
|
| 169 |
} catch (err) {
|
| 170 |
logger.error({ err, organizationId }, '[WHATSAPP-GATEWAY] Forward to worker bridge failed');
|
| 171 |
}
|
|
|
|
| 153 |
// The bridge owns org resolution, WEBHOOK mode handling, and Redis queue ops.
|
| 154 |
// WORKER_BRIDGE_URL env var can override for separate-container deployments.
|
| 155 |
const workerBridgeUrl = process.env.WORKER_BRIDGE_URL || 'http://localhost:8082';
|
| 156 |
+
const traceId = crypto.randomUUID();
|
| 157 |
reply.code(200).send('EVENT_RECEIVED');
|
| 158 |
setImmediate(async () => {
|
| 159 |
try {
|
|
|
|
| 162 |
headers: {
|
| 163 |
'Content-Type': 'application/json',
|
| 164 |
'Authorization': `Bearer ${process.env.ADMIN_API_KEY}`,
|
| 165 |
+
'x-organization-id': organizationId,
|
| 166 |
+
'x-trace-id': traceId
|
| 167 |
},
|
| 168 |
body: JSON.stringify(request.body)
|
| 169 |
});
|
| 170 |
+
logger.info({ status: res.status, organizationId, phoneNumberId, traceId }, '[WHATSAPP-GATEWAY] Forwarded to worker bridge');
|
| 171 |
} catch (err) {
|
| 172 |
logger.error({ err, organizationId }, '[WHATSAPP-GATEWAY] Forward to worker bridge failed');
|
| 173 |
}
|
|
@@ -12,7 +12,7 @@ import { redis } from '../lib/redis';
|
|
| 12 |
|
| 13 |
export class InboundHandler implements JobHandler {
|
| 14 |
async handle(job: Job<JobData>): Promise<void> {
|
| 15 |
-
let { phone, text, audioUrl, imageUrl, organizationId } = job.data;
|
| 16 |
|
| 17 |
if (!phone) {
|
| 18 |
logger.error(`[INBOUND_HANDLER] Missing phone number`);
|
|
@@ -100,7 +100,8 @@ export class InboundHandler implements JobHandler {
|
|
| 100 |
imageUrl,
|
| 101 |
organizationId,
|
| 102 |
audioUrl ? 'audio' : imageUrl ? 'image' : undefined,
|
| 103 |
-
audioUrl || imageUrl || undefined
|
|
|
|
| 104 |
);
|
| 105 |
}
|
| 106 |
}
|
|
|
|
| 12 |
|
| 13 |
export class InboundHandler implements JobHandler {
|
| 14 |
async handle(job: Job<JobData>): Promise<void> {
|
| 15 |
+
let { phone, text, audioUrl, imageUrl, organizationId, traceId } = job.data;
|
| 16 |
|
| 17 |
if (!phone) {
|
| 18 |
logger.error(`[INBOUND_HANDLER] Missing phone number`);
|
|
|
|
| 100 |
imageUrl,
|
| 101 |
organizationId,
|
| 102 |
audioUrl ? 'audio' : imageUrl ? 'image' : undefined,
|
| 103 |
+
audioUrl || imageUrl || undefined,
|
| 104 |
+
traceId
|
| 105 |
);
|
| 106 |
}
|
| 107 |
}
|
|
@@ -89,6 +89,7 @@ export interface JobData {
|
|
| 89 |
accessToken?: string;
|
| 90 |
overrideAudioUrl?: string;
|
| 91 |
sendSpinner?: boolean;
|
|
|
|
| 92 |
|
| 93 |
// Interactive components
|
| 94 |
buttons?: Array<{ id: string; title: string }>;
|
|
|
|
| 89 |
accessToken?: string;
|
| 90 |
overrideAudioUrl?: string;
|
| 91 |
sendSpinner?: boolean;
|
| 92 |
+
traceId?: string;
|
| 93 |
|
| 94 |
// Interactive components
|
| 95 |
buttons?: Array<{ id: string; title: string }>;
|
|
@@ -76,6 +76,7 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
|
|
| 76 |
}
|
| 77 |
|
| 78 |
let organizationId: string | null = req.headers['x-organization-id'] as string || null;
|
|
|
|
| 79 |
const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
|
| 80 |
|
| 81 |
// π’ Multi-Tenant Routing Hardening:
|
|
@@ -123,20 +124,19 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
|
|
| 123 |
|
| 124 |
for (const msg of extractedMessages) {
|
| 125 |
if (msg.text !== undefined) {
|
| 126 |
-
logger.info(`[BRIDGE] Enqueuing inbound message from ${msg.phone}`);
|
| 127 |
await whatsappQueue.add('handle-inbound', {
|
| 128 |
phone: msg.phone,
|
| 129 |
text: msg.text,
|
| 130 |
-
organizationId
|
|
|
|
| 131 |
}, {
|
| 132 |
attempts: 3,
|
| 133 |
backoff: { type: 'exponential', delay: 1000 }
|
| 134 |
});
|
| 135 |
} else if (msg.mediaId) {
|
| 136 |
-
// ποΈ Handle media (Images and Audio)
|
| 137 |
const isImage = msg.mediaType === 'image';
|
| 138 |
-
|
| 139 |
-
logger.info(`[BRIDGE] Enqueuing inbound media (${msg.mediaType}) from ${msg.phone}`);
|
| 140 |
|
| 141 |
// Single media job: MediaHandler handles transcription + DB logging + pedagogy.
|
| 142 |
// A second handle-inbound job was previously enqueued here, causing double
|
|
@@ -147,7 +147,8 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
|
|
| 147 |
phone: msg.phone,
|
| 148 |
organizationId,
|
| 149 |
caption: msg.caption,
|
| 150 |
-
sendSpinner: true
|
|
|
|
| 151 |
}, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
| 152 |
}
|
| 153 |
}
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
let organizationId: string | null = req.headers['x-organization-id'] as string || null;
|
| 79 |
+
const traceId: string = (req.headers['x-trace-id'] as string) || `auto-${Date.now()}`;
|
| 80 |
const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
|
| 81 |
|
| 82 |
// π’ Multi-Tenant Routing Hardening:
|
|
|
|
| 124 |
|
| 125 |
for (const msg of extractedMessages) {
|
| 126 |
if (msg.text !== undefined) {
|
| 127 |
+
logger.info({ traceId }, `[BRIDGE] Enqueuing inbound message from ${msg.phone}`);
|
| 128 |
await whatsappQueue.add('handle-inbound', {
|
| 129 |
phone: msg.phone,
|
| 130 |
text: msg.text,
|
| 131 |
+
organizationId,
|
| 132 |
+
traceId,
|
| 133 |
}, {
|
| 134 |
attempts: 3,
|
| 135 |
backoff: { type: 'exponential', delay: 1000 }
|
| 136 |
});
|
| 137 |
} else if (msg.mediaId) {
|
|
|
|
| 138 |
const isImage = msg.mediaType === 'image';
|
| 139 |
+
logger.info({ traceId }, `[BRIDGE] Enqueuing inbound media (${msg.mediaType}) from ${msg.phone}`);
|
|
|
|
| 140 |
|
| 141 |
// Single media job: MediaHandler handles transcription + DB logging + pedagogy.
|
| 142 |
// A second handle-inbound job was previously enqueued here, causing double
|
|
|
|
| 147 |
phone: msg.phone,
|
| 148 |
organizationId,
|
| 149 |
caption: msg.caption,
|
| 150 |
+
sendSpinner: true,
|
| 151 |
+
traceId,
|
| 152 |
}, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
| 153 |
}
|
| 154 |
}
|
|
@@ -34,19 +34,20 @@ export class WhatsAppLogic {
|
|
| 34 |
}
|
| 35 |
|
| 36 |
static async handleIncomingMessage(
|
| 37 |
-
phone: string,
|
| 38 |
-
text: string,
|
| 39 |
-
audioUrl?: string,
|
| 40 |
-
imageUrl?: string,
|
| 41 |
organizationId?: string,
|
| 42 |
mediaType?: string,
|
| 43 |
-
mediaId?: string
|
|
|
|
| 44 |
) {
|
| 45 |
if (!organizationId) {
|
| 46 |
logger.error({ phone }, '[WHATSAPP_LOGIC] Missing organizationId β refusing to process to prevent cross-tenant contamination');
|
| 47 |
return;
|
| 48 |
}
|
| 49 |
-
const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
|
| 50 |
const normalizedText = this.normalizeCommand(text);
|
| 51 |
logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
|
| 52 |
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
static async handleIncomingMessage(
|
| 37 |
+
phone: string,
|
| 38 |
+
text: string,
|
| 39 |
+
audioUrl?: string,
|
| 40 |
+
imageUrl?: string,
|
| 41 |
organizationId?: string,
|
| 42 |
mediaType?: string,
|
| 43 |
+
mediaId?: string,
|
| 44 |
+
incomingTraceId?: string
|
| 45 |
) {
|
| 46 |
if (!organizationId) {
|
| 47 |
logger.error({ phone }, '[WHATSAPP_LOGIC] Missing organizationId β refusing to process to prevent cross-tenant contamination');
|
| 48 |
return;
|
| 49 |
}
|
| 50 |
+
const traceId = incomingTraceId ?? (audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`);
|
| 51 |
const normalizedText = this.normalizeCommand(text);
|
| 52 |
logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
|
| 53 |
|
|
@@ -25,6 +25,14 @@ export const FlowConfigSchema = z.object({
|
|
| 25 |
export type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
| 26 |
export type Sector = z.infer<typeof SectorSchema>;
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
export const OrganizationSchema = z.object({
|
| 29 |
name: z.string().min(1),
|
| 30 |
contactEmail: z.string().email().optional(),
|
|
@@ -39,7 +47,7 @@ export const OrganizationSchema = z.object({
|
|
| 39 |
knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
|
| 40 |
systemUserToken: z.string().optional(),
|
| 41 |
metaBusinessId: z.string().optional(),
|
| 42 |
-
brandingData:
|
| 43 |
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(),
|
| 44 |
openAiApiKey: z.string().optional(),
|
| 45 |
googleAiApiKey: z.string().optional(),
|
|
|
|
| 25 |
export type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
| 26 |
export type Sector = z.infer<typeof SectorSchema>;
|
| 27 |
|
| 28 |
+
export const BrandingDataSchema = z.object({
|
| 29 |
+
logoUrl: z.string().url().optional(),
|
| 30 |
+
primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
| 31 |
+
orgName: z.string().max(100).optional(),
|
| 32 |
+
}).strict();
|
| 33 |
+
|
| 34 |
+
export type BrandingData = z.infer<typeof BrandingDataSchema>;
|
| 35 |
+
|
| 36 |
export const OrganizationSchema = z.object({
|
| 37 |
name: z.string().min(1),
|
| 38 |
contactEmail: z.string().email().optional(),
|
|
|
|
| 47 |
knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
|
| 48 |
systemUserToken: z.string().optional(),
|
| 49 |
metaBusinessId: z.string().optional(),
|
| 50 |
+
brandingData: BrandingDataSchema.optional(),
|
| 51 |
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(),
|
| 52 |
openAiApiKey: z.string().optional(),
|
| 53 |
googleAiApiKey: z.string().optional(),
|