CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
d74c1b3
·
1 Parent(s): 97f541f

fix(audit): resolve remaining minor technical debt issues

Browse files

AI/BYOK:
- generateText now respects tenant BYOK keys via getProvidersForTenant() (was using global registry)
- generateText now applies exponential backoff retry via AIService.withRetry()

Backend:
- notifications.ts: return 503 instead of 200+undefined when VAPID_PUBLIC_KEY absent
- BillingPage: support WA number now reads from VITE_SUPPORT_WA_NUMBER env var (was hardcoded fake number)
- KnowledgeBasePage: show toast.error on delete failure (was silently swallowed)

DB:
- Add @@index([organizationId, exerciseStatus]) on UserProgress for live-feed query performance
- Add @@index([createdAt]) on AuditLog for time-range admin queries
- Migration 20260513000005 applied to production DB

i18n:
- Add knowledge.delete_error translation key to all 4 locales (fr, en, es, pt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/admin/src/locales/en.json CHANGED
@@ -159,7 +159,8 @@
159
  "search_placeholder": "Search chunks...",
160
  "no_documents": "No chunks found.",
161
  "import_hint": "Import a document in the AI Agent tab to get started.",
162
- "confirm_delete": "Delete this chunk from the knowledge base?"
 
163
  },
164
  "ai_setup": {
165
  "title": "AI Agent Setup",
 
159
  "search_placeholder": "Search chunks...",
160
  "no_documents": "No chunks found.",
161
  "import_hint": "Import a document in the AI Agent tab to get started.",
162
+ "confirm_delete": "Delete this chunk from the knowledge base?",
163
+ "delete_error": "Deletion failed"
164
  },
165
  "ai_setup": {
166
  "title": "AI Agent Setup",
apps/admin/src/locales/es.json CHANGED
@@ -277,7 +277,8 @@
277
  "search_placeholder": "Buscar fragmentos...",
278
  "no_documents": "No se encontraron fragmentos.",
279
  "import_hint": "Importa un documento en la pestaña Agente IA para empezar.",
280
- "confirm_delete": "¿Eliminar este fragmento de la base de conocimiento?"
 
281
  },
282
  "training": {
283
  "title": "Training Lab",
 
277
  "search_placeholder": "Buscar fragmentos...",
278
  "no_documents": "No se encontraron fragmentos.",
279
  "import_hint": "Importa un documento en la pestaña Agente IA para empezar.",
280
+ "confirm_delete": "¿Eliminar este fragmento de la base de conocimiento?",
281
+ "delete_error": "Error al eliminar"
282
  },
283
  "training": {
284
  "title": "Training Lab",
apps/admin/src/locales/fr.json CHANGED
@@ -277,7 +277,8 @@
277
  "search_placeholder": "Rechercher dans les chunks...",
278
  "no_documents": "Aucun chunk trouvé.",
279
  "import_hint": "Importez un document dans l'onglet Agent IA pour commencer.",
280
- "confirm_delete": "Supprimer ce chunk de la base de connaissances ?"
 
281
  },
282
  "training": {
283
  "title": "Training Lab",
 
277
  "search_placeholder": "Rechercher dans les chunks...",
278
  "no_documents": "Aucun chunk trouvé.",
279
  "import_hint": "Importez un document dans l'onglet Agent IA pour commencer.",
280
+ "confirm_delete": "Supprimer ce chunk de la base de connaissances ?",
281
+ "delete_error": "Échec de la suppression"
282
  },
283
  "training": {
284
  "title": "Training Lab",
apps/admin/src/locales/pt.json CHANGED
@@ -277,7 +277,8 @@
277
  "search_placeholder": "Pesquisar fragmentos...",
278
  "no_documents": "Nenhum fragmento encontrado.",
279
  "import_hint": "Importe um documento no separador Agente IA para começar.",
280
- "confirm_delete": "Eliminar este fragmento da base de conhecimento?"
 
281
  },
282
  "training": {
283
  "title": "Training Lab",
 
277
  "search_placeholder": "Pesquisar fragmentos...",
278
  "no_documents": "Nenhum fragmento encontrado.",
279
  "import_hint": "Importe um documento no separador Agente IA para começar.",
280
+ "confirm_delete": "Eliminar este fragmento da base de conhecimento?",
281
+ "delete_error": "Falha ao eliminar"
282
  },
283
  "training": {
284
  "title": "Training Lab",
apps/admin/src/pages/BillingPage.tsx CHANGED
@@ -10,7 +10,10 @@ const CREDIT_PACKS = [
10
  { label: '5 000 crédits', price: '40 000 FCFA', credits: 5000, popular: false },
11
  ];
12
 
13
- const SUPPORT_WA_URL = 'https://wa.me/221700000000?text=Bonjour%2C%20je%20souhaite%20recharger%20mes%20cr%C3%A9dits%20Xaml%C3%A9.';
 
 
 
14
 
15
  const QUICK_QUESTIONS = [
16
  'Combien de crédits me reste-t-il ?',
@@ -67,8 +70,8 @@ function RechargeModal({ onClose }: { onClose: () => void }) {
67
  {CREDIT_PACKS.map(pack => (
68
  <a
69
  key={pack.credits}
70
- href={`${SUPPORT_WA_URL}%20-%20Pack%20${pack.label}`}
71
- target="_blank"
72
  rel="noopener noreferrer"
73
  className={`relative flex items-center justify-between p-4 border rounded-xl transition-all group ${pack.popular ? 'border-indigo-400 bg-indigo-50' : 'border-slate-200 hover:border-indigo-300 hover:bg-indigo-50'}`}
74
  >
 
10
  { label: '5 000 crédits', price: '40 000 FCFA', credits: 5000, popular: false },
11
  ];
12
 
13
+ const SUPPORT_WA_NUMBER = import.meta.env.VITE_SUPPORT_WA_NUMBER as string | undefined;
14
+ const SUPPORT_WA_URL = SUPPORT_WA_NUMBER
15
+ ? `https://wa.me/${SUPPORT_WA_NUMBER}?text=Bonjour%2C%20je%20souhaite%20recharger%20mes%20cr%C3%A9dits%20Xaml%C3%A9.`
16
+ : null;
17
 
18
  const QUICK_QUESTIONS = [
19
  'Combien de crédits me reste-t-il ?',
 
70
  {CREDIT_PACKS.map(pack => (
71
  <a
72
  key={pack.credits}
73
+ href={SUPPORT_WA_URL ? `${SUPPORT_WA_URL}%20-%20Pack%20${pack.label}` : '#'}
74
+ target={SUPPORT_WA_URL ? '_blank' : undefined}
75
  rel="noopener noreferrer"
76
  className={`relative flex items-center justify-between p-4 border rounded-xl transition-all group ${pack.popular ? 'border-indigo-400 bg-indigo-50' : 'border-slate-200 hover:border-indigo-300 hover:bg-indigo-50'}`}
77
  >
apps/admin/src/pages/KnowledgeBasePage.tsx CHANGED
@@ -66,8 +66,9 @@ export default function KnowledgeBasePage() {
66
  try {
67
  await api.delete(`/v1/organizations/${selectedOrgId}/kb/${id}`, token);
68
  await fetchEntries(page);
69
- } catch (err) {
70
  logError('[KB] Delete failed:', err);
 
71
  } finally {
72
  setDeletingId(null);
73
  }
 
66
  try {
67
  await api.delete(`/v1/organizations/${selectedOrgId}/kb/${id}`, token);
68
  await fetchEntries(page);
69
+ } catch (err: any) {
70
  logError('[KB] Delete failed:', err);
71
+ toast.error(err?.message ?? t('knowledge.delete_error'));
72
  } finally {
73
  setDeletingId(null);
74
  }
apps/api/src/routes/notifications.ts CHANGED
@@ -72,7 +72,9 @@ export async function notificationRoutes(fastify: FastifyInstance) {
72
  * GET /v1/notifications/vapid-key
73
  * Returns the public VAPID key for the frontend
74
  */
75
- fastify.get('/vapid-key', async () => {
76
- return { publicKey: process.env.VAPID_PUBLIC_KEY };
 
 
77
  });
78
  }
 
72
  * GET /v1/notifications/vapid-key
73
  * Returns the public VAPID key for the frontend
74
  */
75
+ fastify.get('/vapid-key', async (_req, reply) => {
76
+ const publicKey = process.env.VAPID_PUBLIC_KEY;
77
+ if (!publicKey) return reply.code(503).send({ error: 'Push notifications not configured' });
78
+ return { publicKey };
79
  });
80
  }
packages/ai-sdk/src/index.ts CHANGED
@@ -313,18 +313,20 @@ export class AIService {
313
  }
314
 
315
  async generateText(systemPrompt: string, userPrompt: string, temperature: number = 0.7): Promise<{ text: string; aiSource: string; usage: TokenUsage }> {
316
- const capability = ProviderCapability.TEXT;
317
- const providers = this.registry.getProvidersFor(capability);
318
 
319
  for (const provider of providers) {
320
  try {
321
- const { text, usage } = await provider.instance.generateText(systemPrompt, userPrompt, temperature);
 
 
322
  return { text, aiSource: provider.name, usage };
323
  } catch (err) {
324
  logger.warn(`${provider.name} failed generic text: ${(err as Error).message}.`);
325
  }
326
  }
327
- throw new Error(`[AI_ERROR] All providers for ${capability} failed.`);
328
  }
329
 
330
  async handleCrmConversation(
 
313
  }
314
 
315
  async generateText(systemPrompt: string, userPrompt: string, temperature: number = 0.7): Promise<{ text: string; aiSource: string; usage: TokenUsage }> {
316
+ const organizationId = this.config.getOrganizationId();
317
+ const providers = await this.getProvidersForTenant(ProviderCapability.TEXT, organizationId);
318
 
319
  for (const provider of providers) {
320
  try {
321
+ const { text, usage } = await AIService.withRetry(
322
+ () => provider.instance.generateText(systemPrompt, userPrompt, temperature)
323
+ );
324
  return { text, aiSource: provider.name, usage };
325
  } catch (err) {
326
  logger.warn(`${provider.name} failed generic text: ${(err as Error).message}.`);
327
  }
328
  }
329
+ throw new Error(`[AI_ERROR] All providers for ${ProviderCapability.TEXT} failed.`);
330
  }
331
 
332
  async handleCrmConversation(
packages/database/prisma/migrations/20260513000005_add_missing_indexes/migration.sql ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ -- Add composite index for PENDING_REVIEW live-feed query (most common admin query pattern)
2
+ CREATE INDEX IF NOT EXISTS "UserProgress_organizationId_exerciseStatus_idx"
3
+ ON "UserProgress"("organizationId", "exerciseStatus");
4
+
5
+ -- Add createdAt index on AuditLog for time-range queries
6
+ CREATE INDEX IF NOT EXISTS "AuditLog_createdAt_idx"
7
+ ON "AuditLog"("createdAt");
packages/database/prisma/schema.prisma CHANGED
@@ -309,6 +309,7 @@ model UserProgress {
309
 
310
  @@unique([userId, trackId])
311
  @@index([organizationId])
 
312
  }
313
 
314
  model Enrollment {
@@ -612,4 +613,5 @@ model AuditLog {
612
  @@index([action])
613
  @@index([actorId])
614
  @@index([resourceId])
 
615
  }
 
309
 
310
  @@unique([userId, trackId])
311
  @@index([organizationId])
312
+ @@index([organizationId, exerciseStatus])
313
  }
314
 
315
  model Enrollment {
 
613
  @@index([action])
614
  @@index([actorId])
615
  @@index([resourceId])
616
+ @@index([createdAt])
617
  }