CognxSafeTrack commited on
Commit ·
c37b68c
1
Parent(s): b035578
feat: implement organization-specific API keys and tenant-aware webhooks (Phase 2)
Browse files- apps/api/src/routes/payments.ts +8 -3
- apps/api/src/routes/whatsapp.ts +52 -132
- apps/api/src/services/ai/index.ts +43 -3
- apps/api/src/services/organization.ts +23 -0
- apps/api/src/services/stripe.ts +41 -13
apps/api/src/routes/payments.ts
CHANGED
|
@@ -103,7 +103,12 @@ export async function stripeWebhookRoute(fastify: FastifyInstance) {
|
|
| 103 |
}
|
| 104 |
});
|
| 105 |
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
const sig = request.headers['stripe-signature'];
|
| 108 |
|
| 109 |
if (!sig || typeof sig !== 'string') {
|
|
@@ -115,10 +120,10 @@ export async function stripeWebhookRoute(fastify: FastifyInstance) {
|
|
| 115 |
try {
|
| 116 |
const rawBody = request.rawBody;
|
| 117 |
if (!rawBody) throw new Error('Missing raw body');
|
| 118 |
-
event = stripeService.verifyWebhookSignature(rawBody, sig);
|
| 119 |
} catch (err: unknown) {
|
| 120 |
const errorMsg = err instanceof Error ? err.message : String(err);
|
| 121 |
-
fastify.log.warn(`[Stripe Webhook] Signature verification failed: ${errorMsg}`);
|
| 122 |
return reply.status(400).send(`Webhook Error: ${errorMsg}`);
|
| 123 |
}
|
| 124 |
|
|
|
|
| 103 |
}
|
| 104 |
});
|
| 105 |
|
| 106 |
+
// ── POST /webhook or /webhook/:organizationId ───────────────────────────
|
| 107 |
+
fastify.post('/webhook', async (request, reply) => handleWebhook(request, reply));
|
| 108 |
+
fastify.post('/webhook/:organizationId', async (request, reply) => handleWebhook(request, reply));
|
| 109 |
+
|
| 110 |
+
async function handleWebhook(request: any, reply: any) {
|
| 111 |
+
const { organizationId } = request.params as { organizationId?: string };
|
| 112 |
const sig = request.headers['stripe-signature'];
|
| 113 |
|
| 114 |
if (!sig || typeof sig !== 'string') {
|
|
|
|
| 120 |
try {
|
| 121 |
const rawBody = request.rawBody;
|
| 122 |
if (!rawBody) throw new Error('Missing raw body');
|
| 123 |
+
event = await stripeService.verifyWebhookSignature(rawBody, sig, organizationId);
|
| 124 |
} catch (err: unknown) {
|
| 125 |
const errorMsg = err instanceof Error ? err.message : String(err);
|
| 126 |
+
fastify.log.warn(`[Stripe Webhook] Signature verification failed for Org ${organizationId || 'global'}: ${errorMsg}`);
|
| 127 |
return reply.status(400).send(`Webhook Error: ${errorMsg}`);
|
| 128 |
}
|
| 129 |
|
apps/api/src/routes/whatsapp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { FastifyInstance } from 'fastify';
|
|
| 3 |
import crypto from 'crypto';
|
| 4 |
import { z } from 'zod';
|
| 5 |
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
|
|
|
| 6 |
|
| 7 |
// ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
|
| 8 |
const WhatsAppMessageSchema = z.object({
|
|
@@ -65,11 +66,7 @@ function verifyWebhookSignature(rawBody: Buffer, signature: string | undefined,
|
|
| 65 |
|
| 66 |
// ─── Route Plugin ─────────────────────────────────────────────────────────────
|
| 67 |
export async function whatsappRoutes(fastify: FastifyInstance) {
|
| 68 |
-
// ── Raw body capture for HMAC verification ──────────────────────────────
|
| 69 |
-
// We need the raw buffer BEFORE JSON.parse, so we override the content type parser
|
| 70 |
-
// for this specific route scope only.
|
| 71 |
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
|
| 72 |
-
// Store raw body on request for signature verification
|
| 73 |
req.rawBody = body as Buffer;
|
| 74 |
try {
|
| 75 |
done(null, JSON.parse(body.toString('utf8')));
|
|
@@ -78,159 +75,82 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
|
|
| 78 |
}
|
| 79 |
});
|
| 80 |
|
| 81 |
-
// ── GET /webhook — Meta verification handshake ──────────────────────────
|
| 82 |
fastify.get('/webhook', async (request, reply) => {
|
| 83 |
const query = request.query as Record<string, string>;
|
| 84 |
const mode = query['hub.mode'];
|
| 85 |
const token = query['hub.verify_token'];
|
| 86 |
const challenge = query['hub.challenge'];
|
| 87 |
|
| 88 |
-
|
| 89 |
-
if (!mode && !token && !challenge) {
|
| 90 |
-
return reply.code(200).type('text/plain').send('ok');
|
| 91 |
-
}
|
| 92 |
|
| 93 |
-
// Meta verification
|
| 94 |
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
|
| 95 |
-
request.log.info('[WEBHOOK] Meta verification successful');
|
| 96 |
return reply.code(200).type('text/plain').send(challenge);
|
| 97 |
}
|
| 98 |
-
|
| 99 |
-
request.log.warn('[WEBHOOK] Meta verification failed — token mismatch or wrong mode');
|
| 100 |
-
return reply.code(403).type('text/plain').send('Forbidden');
|
| 101 |
});
|
| 102 |
|
| 103 |
-
/
|
| 104 |
-
fastify.post('/webhook', async (request, reply) =>
|
| 105 |
-
// ── 1. HMAC Signature Verification ──────────────────────────────────
|
| 106 |
-
logger.info("[RAW-WHATSAPP-PAYLOAD]", JSON.stringify(request.body, null, 2));
|
| 107 |
-
const appSecret = process.env.WHATSAPP_APP_SECRET;
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
if (appSecret) {
|
| 110 |
const signature = request.headers['x-hub-signature-256'] as string;
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
// Optional: verify webhook signature. Important for production on both HF and Railway.
|
| 114 |
-
if (!rawBody || !verifyWebhookSignature(rawBody, signature, appSecret)) {
|
| 115 |
-
request.log.warn('[WEBHOOK] Invalid HMAC signature — request rejected');
|
| 116 |
-
// Meta won't retry if we return 401/403 directly for invalid signatures.
|
| 117 |
return reply.code(403).send({ error: 'Invalid signature' });
|
| 118 |
}
|
| 119 |
-
} else {
|
| 120 |
-
// Log a warning but allow in development (no secret set)
|
| 121 |
-
request.log.warn('[WEBHOOK] WHATSAPP_APP_SECRET not set — skipping signature verification');
|
| 122 |
}
|
| 123 |
|
| 124 |
-
//
|
| 125 |
const railwayInternalUrl = process.env.RAILWAY_INTERNAL_URL;
|
| 126 |
-
|
| 127 |
-
// Hardened Gateway Detection:
|
| 128 |
-
// We are a gateway if:
|
| 129 |
-
// 1. IS_GATEWAY is explicitly true
|
| 130 |
-
// 2. We are running on Hugging Face (HF_SPACE_ID exists)
|
| 131 |
-
// 3. We have a RAILWAY_INTERNAL_URL but NOT a RAILWAY_STATIC_URL (not on Railway)
|
| 132 |
-
const isGateway =
|
| 133 |
-
process.env.IS_GATEWAY === 'true' ||
|
| 134 |
-
process.env.HF_SPACE_ID !== undefined ||
|
| 135 |
-
(!!railwayInternalUrl && !process.env.RAILWAY_STATIC_URL);
|
| 136 |
|
| 137 |
if (railwayInternalUrl && isGateway) {
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
headers: {
|
| 151 |
-
'Content-Type': 'application/json',
|
| 152 |
-
'Authorization': `Bearer ${process.env.ADMIN_API_KEY || ''}`,
|
| 153 |
-
'X-Organization-Id': request.headers['x-organization-id'] as string || 'default-org-id'
|
| 154 |
-
},
|
| 155 |
-
body: request.body ? JSON.stringify(request.body) : ''
|
| 156 |
-
}).then(res => {
|
| 157 |
-
request.log.info(`[WEBHOOK] Gateway forward result: ${res.status} ${res.statusText}`);
|
| 158 |
-
}).catch(err => {
|
| 159 |
-
request.log.error(`[WEBHOOK] Forward to Railway failed: ${err instanceof Error ? err.message : String(err)}`);
|
| 160 |
-
});
|
| 161 |
-
|
| 162 |
-
// 🚨 CRITICAL: CRUCIAL EXIT POINT FOR GATEWAY
|
| 163 |
-
return reply.code(200).send('EVENT_RECEIVED');
|
| 164 |
-
} catch (error: unknown) {
|
| 165 |
-
request.log.error(`[WEBHOOK] Forward throwing error: ${error instanceof Error ? error.message : String(error)}`);
|
| 166 |
-
// Still return 200 to Meta to avoid retries, even if gateway forward failed internally
|
| 167 |
-
return reply.code(200).send('EVENT_RECEIVED_FW_ERR');
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
// ── 3. DETACH IMMEDIATELY ──
|
| 173 |
-
// Respond 200 OK right now to release the HF Gateway connection.
|
| 174 |
-
// We do this BEFORE any parsing or work.
|
| 175 |
-
if (!reply.sent) {
|
| 176 |
-
reply.code(200).send('EVENT_RECEIVED');
|
| 177 |
}
|
| 178 |
|
| 179 |
-
//
|
| 180 |
-
const
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
const
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
if (msg.text !== undefined) {
|
| 204 |
-
await scheduleInboundMessage({
|
| 205 |
-
phone: msg.phone,
|
| 206 |
-
text: msg.text,
|
| 207 |
-
messageId: msg.messageId,
|
| 208 |
-
organizationId
|
| 209 |
-
});
|
| 210 |
-
} else if (msg.mediaId && msg.mediaType) {
|
| 211 |
-
const accessToken = process.env.WHATSAPP_ACCESS_TOKEN || undefined;
|
| 212 |
-
const { whatsappQueue: q } = await import('../services/queue');
|
| 213 |
-
|
| 214 |
-
await q.add('download-media', {
|
| 215 |
-
mediaId: msg.mediaId,
|
| 216 |
-
mimeType: msg.mediaType === 'image' ? 'image/jpeg' : 'audio/ogg',
|
| 217 |
-
phone: msg.phone,
|
| 218 |
-
organizationId,
|
| 219 |
-
caption: msg.caption,
|
| 220 |
-
...(accessToken ? { accessToken } : {})
|
| 221 |
-
}, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
|
| 222 |
-
|
| 223 |
-
await q.add('send-message-direct', {
|
| 224 |
-
phone: msg.phone,
|
| 225 |
-
text: msg.mediaType === 'image' ? "⏳ J'analyse ton image..." : "⏳ J'analyse ton audio...",
|
| 226 |
-
organizationId
|
| 227 |
-
});
|
| 228 |
-
}
|
| 229 |
}
|
| 230 |
-
} catch (error) {
|
| 231 |
-
fastify.log.error(`[WEBHOOK] Detached processing error: ${String(error)}`);
|
| 232 |
}
|
| 233 |
-
}
|
| 234 |
|
| 235 |
-
|
|
|
|
| 236 |
}
|
|
|
|
| 3 |
import crypto from 'crypto';
|
| 4 |
import { z } from 'zod';
|
| 5 |
import { getOrganizationByPhoneNumberId } from '../services/organization';
|
| 6 |
+
import { whatsappQueue } from '../services/queue';
|
| 7 |
|
| 8 |
// ─── Zod Schema for WhatsApp Webhook Payload ─────────────────────────────────
|
| 9 |
const WhatsAppMessageSchema = z.object({
|
|
|
|
| 66 |
|
| 67 |
// ─── Route Plugin ─────────────────────────────────────────────────────────────
|
| 68 |
export async function whatsappRoutes(fastify: FastifyInstance) {
|
|
|
|
|
|
|
|
|
|
| 69 |
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
|
|
|
|
| 70 |
req.rawBody = body as Buffer;
|
| 71 |
try {
|
| 72 |
done(null, JSON.parse(body.toString('utf8')));
|
|
|
|
| 75 |
}
|
| 76 |
});
|
| 77 |
|
|
|
|
| 78 |
fastify.get('/webhook', async (request, reply) => {
|
| 79 |
const query = request.query as Record<string, string>;
|
| 80 |
const mode = query['hub.mode'];
|
| 81 |
const token = query['hub.verify_token'];
|
| 82 |
const challenge = query['hub.challenge'];
|
| 83 |
|
| 84 |
+
if (!mode && !token && !challenge) return reply.code(200).type('text/plain').send('ok');
|
|
|
|
|
|
|
|
|
|
| 85 |
|
|
|
|
| 86 |
if (mode === 'subscribe' && token === process.env.WHATSAPP_VERIFY_TOKEN) {
|
|
|
|
| 87 |
return reply.code(200).type('text/plain').send(challenge);
|
| 88 |
}
|
| 89 |
+
return reply.code(403).send('Forbidden');
|
|
|
|
|
|
|
| 90 |
});
|
| 91 |
|
| 92 |
+
fastify.post('/webhook', async (request, reply) => handleIncoming(request, reply));
|
| 93 |
+
fastify.post('/webhook/:organizationId', async (request, reply) => handleIncoming(request, reply));
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
async function handleIncoming(request: any, reply: any) {
|
| 96 |
+
const { organizationId: urlOrgId } = request.params as { organizationId?: string };
|
| 97 |
+
|
| 98 |
+
// 1. HMAC Verification
|
| 99 |
+
const appSecret = process.env.WHATSAPP_APP_SECRET;
|
| 100 |
if (appSecret) {
|
| 101 |
const signature = request.headers['x-hub-signature-256'] as string;
|
| 102 |
+
if (!request.rawBody || !verifyWebhookSignature(request.rawBody, signature, appSecret)) {
|
| 103 |
+
request.log.warn(`[WEBHOOK] Invalid HMAC for Org ${urlOrgId || 'global'}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
return reply.code(403).send({ error: 'Invalid signature' });
|
| 105 |
}
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
+
// 2. Gateway Forwarding
|
| 109 |
const railwayInternalUrl = process.env.RAILWAY_INTERNAL_URL;
|
| 110 |
+
const isGateway = process.env.IS_GATEWAY === 'true' || !!process.env.HF_SPACE_ID;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
if (railwayInternalUrl && isGateway) {
|
| 113 |
+
const targetUrl = `${railwayInternalUrl.replace(/\/$/, '')}/v1/internal/whatsapp/inbound`;
|
| 114 |
+
fetch(targetUrl, {
|
| 115 |
+
method: 'POST',
|
| 116 |
+
headers: {
|
| 117 |
+
'Content-Type': 'application/json',
|
| 118 |
+
'Authorization': `Bearer ${process.env.ADMIN_API_KEY}`,
|
| 119 |
+
'x-organization-id': urlOrgId || ''
|
| 120 |
+
},
|
| 121 |
+
body: JSON.stringify(request.body)
|
| 122 |
+
}).catch(err => logger.error('[WEBHOOK] Forwarding failed:', err));
|
| 123 |
+
|
| 124 |
+
return reply.code(200).send({ status: 'forwarded' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
|
| 127 |
+
// 3. Queue for Processing
|
| 128 |
+
const parsed = WebhookPayloadSchema.safeParse(request.body);
|
| 129 |
+
if (!parsed.success) return reply.code(200).send({ status: 'ignored' });
|
| 130 |
+
|
| 131 |
+
for (const entry of parsed.data.entry) {
|
| 132 |
+
for (const change of entry.changes) {
|
| 133 |
+
const phoneNumberId = change.value.metadata?.phone_number_id || 'unknown';
|
| 134 |
+
|
| 135 |
+
// Use URL OrgId if present, otherwise resolve from phone number
|
| 136 |
+
const organizationId = urlOrgId || await getOrganizationByPhoneNumberId(phoneNumberId);
|
| 137 |
+
|
| 138 |
+
for (const message of change.value.messages || []) {
|
| 139 |
+
await whatsappQueue.add('process-message', {
|
| 140 |
+
message,
|
| 141 |
+
organizationId,
|
| 142 |
+
metadata: {
|
| 143 |
+
phoneNumberId,
|
| 144 |
+
displayPhoneNumber: change.value.metadata?.display_phone_number
|
| 145 |
+
}
|
| 146 |
+
}, {
|
| 147 |
+
attempts: 3,
|
| 148 |
+
backoff: { type: 'exponential', delay: 1000 }
|
| 149 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
+
}
|
| 153 |
|
| 154 |
+
return reply.code(200).send({ status: 'received' });
|
| 155 |
+
}
|
| 156 |
}
|
apps/api/src/services/ai/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { getOrganizationId } from '@repo/database';
|
|
| 15 |
import { prisma } from '../prisma';
|
| 16 |
import { redis } from '../queue';
|
| 17 |
import { ProviderRegistry, ProviderCapability } from './ProviderRegistry';
|
|
|
|
| 18 |
|
| 19 |
class AIService {
|
| 20 |
private registry: ProviderRegistry;
|
|
@@ -75,22 +76,61 @@ class AIService {
|
|
| 75 |
}
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
private async callWithFailover<T>(
|
| 79 |
prompt: string,
|
| 80 |
schema: z.ZodSchema<T>,
|
| 81 |
temperature?: number,
|
| 82 |
imageUrl?: string
|
| 83 |
): Promise<{ data: T, source: string }> {
|
|
|
|
| 84 |
const capability = imageUrl ? ProviderCapability.VISION : ProviderCapability.TEXT;
|
| 85 |
-
const providers = this.
|
| 86 |
|
| 87 |
for (const provider of providers) {
|
| 88 |
try {
|
| 89 |
const data = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl);
|
| 90 |
-
logger.info(`[AI_INFO] ${provider.name} used successfully. (Capability: ${capability})`);
|
| 91 |
return { data, source: provider.name };
|
| 92 |
} catch (err) {
|
| 93 |
-
logger.warn(`[AI_WARNING] ${provider.name} failed: ${(err as Error).message}. Trying next provider...`);
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
|
|
|
| 15 |
import { prisma } from '../prisma';
|
| 16 |
import { redis } from '../queue';
|
| 17 |
import { ProviderRegistry, ProviderCapability } from './ProviderRegistry';
|
| 18 |
+
import { getTenantSecrets } from '../organization';
|
| 19 |
|
| 20 |
class AIService {
|
| 21 |
private registry: ProviderRegistry;
|
|
|
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
| 79 |
+
private async getProvidersForTenant(capability: ProviderCapability, organizationId?: string) {
|
| 80 |
+
if (!organizationId) return this.registry.getProvidersFor(capability);
|
| 81 |
+
|
| 82 |
+
// Check if tenant has custom keys
|
| 83 |
+
const secrets = await getTenantSecrets(organizationId);
|
| 84 |
+
if (!secrets || (!secrets.openAiApiKey && !secrets.googleAiApiKey)) {
|
| 85 |
+
return this.registry.getProvidersFor(capability);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Create temporary registry for this request with tenant keys
|
| 89 |
+
const tenantRegistry = new ProviderRegistry();
|
| 90 |
+
|
| 91 |
+
if (secrets.googleAiApiKey) {
|
| 92 |
+
tenantRegistry.register('GEMINI_TENANT', new GeminiProvider(secrets.googleAiApiKey), 1000, [
|
| 93 |
+
ProviderCapability.TEXT,
|
| 94 |
+
ProviderCapability.VISION
|
| 95 |
+
]);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (secrets.openAiApiKey) {
|
| 99 |
+
tenantRegistry.register('OPENAI_TENANT', new OpenAIProvider(secrets.openAiApiKey), 500, [
|
| 100 |
+
ProviderCapability.TEXT,
|
| 101 |
+
ProviderCapability.VISION,
|
| 102 |
+
ProviderCapability.AUDIO_TRANSCRIPTION,
|
| 103 |
+
ProviderCapability.SPEECH_GENERATION,
|
| 104 |
+
ProviderCapability.IMAGE_GENERATION
|
| 105 |
+
]);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// Add global providers as fallback
|
| 109 |
+
const globalProviders = this.registry.getProvidersFor(capability);
|
| 110 |
+
for (const p of globalProviders) {
|
| 111 |
+
tenantRegistry.register(p.name, p.instance, p.priority, p.capabilities);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
return tenantRegistry.getProvidersFor(capability);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
private async callWithFailover<T>(
|
| 118 |
prompt: string,
|
| 119 |
schema: z.ZodSchema<T>,
|
| 120 |
temperature?: number,
|
| 121 |
imageUrl?: string
|
| 122 |
): Promise<{ data: T, source: string }> {
|
| 123 |
+
const organizationId = getOrganizationId();
|
| 124 |
const capability = imageUrl ? ProviderCapability.VISION : ProviderCapability.TEXT;
|
| 125 |
+
const providers = await this.getProvidersForTenant(capability, organizationId);
|
| 126 |
|
| 127 |
for (const provider of providers) {
|
| 128 |
try {
|
| 129 |
const data = await provider.instance.generateStructuredData(prompt, schema, temperature, imageUrl);
|
| 130 |
+
logger.info(`[AI_INFO] ${provider.name} used successfully for Org: ${organizationId || 'global'}. (Capability: ${capability})`);
|
| 131 |
return { data, source: provider.name };
|
| 132 |
} catch (err) {
|
| 133 |
+
logger.warn(`[AI_WARNING] ${provider.name} failed for Org ${organizationId || 'global'}: ${(err as Error).message}. Trying next provider...`);
|
| 134 |
}
|
| 135 |
}
|
| 136 |
|
apps/api/src/services/organization.ts
CHANGED
|
@@ -73,5 +73,28 @@ export function encryptSecrets(data: any) {
|
|
| 73 |
export function decryptSecrets(org: any) {
|
| 74 |
if (org.systemUserToken) org.systemUserToken = decrypt(org.systemUserToken, ENCRYPTION_SECRET);
|
| 75 |
if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
|
|
|
|
|
|
|
|
|
|
| 76 |
return org;
|
| 77 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
export function decryptSecrets(org: any) {
|
| 74 |
if (org.systemUserToken) org.systemUserToken = decrypt(org.systemUserToken, ENCRYPTION_SECRET);
|
| 75 |
if (org.webhookSecret) org.webhookSecret = decrypt(org.webhookSecret, ENCRYPTION_SECRET);
|
| 76 |
+
if (org.openAiApiKey) org.openAiApiKey = decrypt(org.openAiApiKey, ENCRYPTION_SECRET);
|
| 77 |
+
if (org.googleAiApiKey) org.googleAiApiKey = decrypt(org.googleAiApiKey, ENCRYPTION_SECRET);
|
| 78 |
+
if (org.stripeSecretKey) org.stripeSecretKey = decrypt(org.stripeSecretKey, ENCRYPTION_SECRET);
|
| 79 |
return org;
|
| 80 |
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Retrieves all secrets for a tenant, decrypted.
|
| 84 |
+
*/
|
| 85 |
+
export async function getTenantSecrets(organizationId: string) {
|
| 86 |
+
const org = await prisma.organization.findUnique({
|
| 87 |
+
where: { id: organizationId },
|
| 88 |
+
select: {
|
| 89 |
+
systemUserToken: true,
|
| 90 |
+
webhookSecret: true,
|
| 91 |
+
openAiApiKey: true,
|
| 92 |
+
googleAiApiKey: true,
|
| 93 |
+
stripeSecretKey: true,
|
| 94 |
+
stripeWebhookSecret: true
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
if (!org) return null;
|
| 99 |
+
return decryptSecrets(org);
|
| 100 |
+
}
|
apps/api/src/services/stripe.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
import { logger } from '../logger';
|
| 2 |
import Stripe from 'stripe';
|
| 3 |
import { PaymentProvider, CheckoutSessionParams } from './payments/types';
|
|
|
|
| 4 |
|
| 5 |
export class StripeService implements PaymentProvider {
|
| 6 |
public name = 'stripe';
|
| 7 |
-
private stripe: Stripe | null =
|
| 8 |
-
private webhookSecret: string | null = null;
|
| 9 |
-
private clientUrl: string;
|
| 10 |
|
| 11 |
constructor() {
|
| 12 |
const secretKey = process.env.STRIPE_SECRET_KEY;
|
|
@@ -22,11 +21,37 @@ export class StripeService implements PaymentProvider {
|
|
| 22 |
}
|
| 23 |
}
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
/**
|
| 26 |
* Unified checkout session creator for the interface
|
| 27 |
*/
|
| 28 |
async createCheckoutSession(params: CheckoutSessionParams): Promise<string> {
|
| 29 |
-
|
|
|
|
| 30 |
|
| 31 |
// Decide mode based on params
|
| 32 |
const isSubscription = !!params.organizationId && !params.trackId;
|
|
@@ -36,7 +61,7 @@ export class StripeService implements PaymentProvider {
|
|
| 36 |
if (!priceId) throw new Error('[StripeService] Missing Price ID for checkout');
|
| 37 |
|
| 38 |
try {
|
| 39 |
-
const session = await
|
| 40 |
payment_method_types: ['card'],
|
| 41 |
line_items: [{ price: priceId, quantity: 1 }],
|
| 42 |
mode: mode as any,
|
|
@@ -75,19 +100,21 @@ export class StripeService implements PaymentProvider {
|
|
| 75 |
/**
|
| 76 |
* Verifies the signature of an incoming Stripe webhook.
|
| 77 |
*/
|
| 78 |
-
verifyWebhookSignature(payload: Buffer, signature: string | undefined): Stripe.Event {
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
if (!signature) {
|
| 83 |
throw new Error('Missing stripe-signature header');
|
| 84 |
}
|
| 85 |
|
| 86 |
try {
|
| 87 |
-
return
|
| 88 |
payload,
|
| 89 |
signature,
|
| 90 |
-
|
| 91 |
);
|
| 92 |
} catch (err: unknown) {
|
| 93 |
throw new Error(`Webhook Error: ${(err instanceof Error ? err.message : String(err))}`);
|
|
@@ -97,11 +124,12 @@ export class StripeService implements PaymentProvider {
|
|
| 97 |
/**
|
| 98 |
* Creates a link to the Stripe Customer Portal for subscription management.
|
| 99 |
*/
|
| 100 |
-
async createCustomerPortalSession(customerId: string) {
|
| 101 |
-
|
|
|
|
| 102 |
|
| 103 |
try {
|
| 104 |
-
const session = await
|
| 105 |
customer: customerId,
|
| 106 |
return_url: `${this.clientUrl}/settings`,
|
| 107 |
});
|
|
|
|
| 1 |
import { logger } from '../logger';
|
| 2 |
import Stripe from 'stripe';
|
| 3 |
import { PaymentProvider, CheckoutSessionParams } from './payments/types';
|
| 4 |
+
import { getTenantSecrets } from './organization';
|
| 5 |
|
| 6 |
export class StripeService implements PaymentProvider {
|
| 7 |
public name = 'stripe';
|
| 8 |
+
private instances: Map<string, { stripe: Stripe, webhookSecret: string | null }> = new Map();
|
|
|
|
|
|
|
| 9 |
|
| 10 |
constructor() {
|
| 11 |
const secretKey = process.env.STRIPE_SECRET_KEY;
|
|
|
|
| 21 |
}
|
| 22 |
}
|
| 23 |
|
| 24 |
+
private async getStripeInstance(organizationId?: string): Promise<{ stripe: Stripe | null, webhookSecret: string | null }> {
|
| 25 |
+
if (!organizationId) return { stripe: this.stripe, webhookSecret: this.webhookSecret };
|
| 26 |
+
|
| 27 |
+
// Check cache
|
| 28 |
+
if (this.instances.has(organizationId)) {
|
| 29 |
+
return this.instances.get(organizationId)!;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
// Check DB for tenant secrets
|
| 33 |
+
const secrets = await getTenantSecrets(organizationId);
|
| 34 |
+
if (secrets?.stripeSecretKey) {
|
| 35 |
+
const instance = {
|
| 36 |
+
stripe: new Stripe(secrets.stripeSecretKey, {
|
| 37 |
+
apiVersion: '2025-01-27.acacia' as any,
|
| 38 |
+
}),
|
| 39 |
+
webhookSecret: secrets.stripeWebhookSecret || null
|
| 40 |
+
};
|
| 41 |
+
this.instances.set(organizationId, instance);
|
| 42 |
+
return instance;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Fallback to global
|
| 46 |
+
return { stripe: this.stripe, webhookSecret: this.webhookSecret };
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
/**
|
| 50 |
* Unified checkout session creator for the interface
|
| 51 |
*/
|
| 52 |
async createCheckoutSession(params: CheckoutSessionParams): Promise<string> {
|
| 53 |
+
const { stripe } = await this.getStripeInstance(params.organizationId);
|
| 54 |
+
if (!stripe) throw new Error('[StripeService] Stripe is not configured for this organization');
|
| 55 |
|
| 56 |
// Decide mode based on params
|
| 57 |
const isSubscription = !!params.organizationId && !params.trackId;
|
|
|
|
| 61 |
if (!priceId) throw new Error('[StripeService] Missing Price ID for checkout');
|
| 62 |
|
| 63 |
try {
|
| 64 |
+
const session = await stripe.checkout.sessions.create({
|
| 65 |
payment_method_types: ['card'],
|
| 66 |
line_items: [{ price: priceId, quantity: 1 }],
|
| 67 |
mode: mode as any,
|
|
|
|
| 100 |
/**
|
| 101 |
* Verifies the signature of an incoming Stripe webhook.
|
| 102 |
*/
|
| 103 |
+
async verifyWebhookSignature(payload: Buffer, signature: string | undefined, organizationId?: string): Promise<Stripe.Event> {
|
| 104 |
+
const { stripe, webhookSecret } = await this.getStripeInstance(organizationId);
|
| 105 |
+
|
| 106 |
+
if (!stripe || !webhookSecret) {
|
| 107 |
+
throw new Error('[StripeService] Stripe is not configured for this organization');
|
| 108 |
}
|
| 109 |
if (!signature) {
|
| 110 |
throw new Error('Missing stripe-signature header');
|
| 111 |
}
|
| 112 |
|
| 113 |
try {
|
| 114 |
+
return stripe.webhooks.constructEvent(
|
| 115 |
payload,
|
| 116 |
signature,
|
| 117 |
+
webhookSecret
|
| 118 |
);
|
| 119 |
} catch (err: unknown) {
|
| 120 |
throw new Error(`Webhook Error: ${(err instanceof Error ? err.message : String(err))}`);
|
|
|
|
| 124 |
/**
|
| 125 |
* Creates a link to the Stripe Customer Portal for subscription management.
|
| 126 |
*/
|
| 127 |
+
async createCustomerPortalSession(customerId: string, organizationId?: string) {
|
| 128 |
+
const { stripe } = await this.getStripeInstance(organizationId);
|
| 129 |
+
if (!stripe) throw new Error('[StripeService] Stripe not configured for this organization');
|
| 130 |
|
| 131 |
try {
|
| 132 |
+
const session = await stripe.billingPortal.sessions.create({
|
| 133 |
customer: customerId,
|
| 134 |
return_url: `${this.clientUrl}/settings`,
|
| 135 |
});
|