fix(audit): resolve remaining minor technical debt issues
Browse filesAI/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 +2 -1
- apps/admin/src/locales/es.json +2 -1
- apps/admin/src/locales/fr.json +2 -1
- apps/admin/src/locales/pt.json +2 -1
- apps/admin/src/pages/BillingPage.tsx +6 -3
- apps/admin/src/pages/KnowledgeBasePage.tsx +2 -1
- apps/api/src/routes/notifications.ts +4 -2
- packages/ai-sdk/src/index.ts +6 -4
- packages/database/prisma/migrations/20260513000005_add_missing_indexes/migration.sql +7 -0
- packages/database/prisma/schema.prisma +2 -0
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
>
|
|
@@ -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 |
}
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 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 |
}
|
|
@@ -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
|
| 317 |
-
const providers = this.
|
| 318 |
|
| 319 |
for (const provider of providers) {
|
| 320 |
try {
|
| 321 |
-
const { text, usage } = await
|
|
|
|
|
|
|
| 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 ${
|
| 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(
|
|
@@ -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");
|
|
@@ -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 |
}
|