import { supabase } from "../lib/supabase.js"; import { canUserSimulate } from "../lib/access.js"; import type { CorrectionRapport, ProductionModele, ExerciceItem, } from "../lib/deepseek.js"; import type { AuthProfile } from "../middleware/auth.js"; export type JobStatus = "pending" | "ready" | "error"; export type Tache = | "EE_T1" | "EE_T2" | "EE_T3" | "EO_T1" | "EO_T3" | "EO_T2_LIVE"; export type Mode = "entrainement" | "examen"; export interface CreateBody { tache: Tache; mode: Mode; contenu?: string; } export interface SujetData { id: string; consigne: string; role: string | null; contexte: string | null; doc1_titre: string | null; doc1_texte: string | null; doc2_titre: string | null; doc2_texte: string | null; } export interface CreateResult { id: string; tache: Tache; mode: Mode; created_at: string; sujet: SujetData | null; } type CreateError = { error: true; code: string; message: string; status: number; }; // Mappe une Tache frontend vers les filtres de la table sujets. // Retourne null pour EO_T2_LIVE (interaction live, pas de sujet pré-défini). function mapTacheToSujetParams( tache: Tache, ): { mode: "EE" | "EO"; tacheNumber: number } | null { switch (tache) { case "EE_T1": return { mode: "EE", tacheNumber: 1 }; case "EE_T2": return { mode: "EE", tacheNumber: 2 }; case "EE_T3": return { mode: "EE", tacheNumber: 3 }; case "EO_T1": return { mode: "EO", tacheNumber: 1 }; case "EO_T3": return { mode: "EO", tacheNumber: 3 }; case "EO_T2_LIVE": return null; } } export async function create( body: CreateBody, profile: AuthProfile, ): Promise<{ data: CreateResult } | CreateError> { // 1. Vérifier le quota via canUserSimulate (lib/access.ts) const check = canUserSimulate({ plan: profile.plan, simulations_used: profile.simulations_used, }); if (!check.allowed) { return { error: true, code: "QUOTA_REACHED", message: "Vous avez utilisé vos 5 simulations gratuites. Passez en Standard pour continuer votre préparation.", status: 403, }; } // 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête. // (non bloquant — sujet: null si introuvable). const sujetParams = mapTacheToSujetParams(body.tache); let sujet: SujetData | null = null; if (sujetParams) { const { data: sujets, error: sujetError } = await supabase .from("sujets") .select( "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte", ) .eq("mode", sujetParams.mode) .eq("tache", sujetParams.tacheNumber) .eq("actif", true); if (!sujetError && sujets && sujets.length > 0) { sujet = sujets[Math.floor(Math.random() * sujets.length)] as SujetData; } } // 3. Insérer dans productions avec sujet_id (FTD-21 — persistance pour resume). const { data, error } = await supabase .from("productions") .insert({ user_id: profile.id, tache: body.tache, mode: body.mode, contenu: body.contenu ?? null, sujet_id: sujet?.id ?? null, }) .select("id, tache, mode, created_at") .single(); if (error || !data) { return { error: true, code: "INTERNAL_ERROR", message: "Une erreur est survenue. Veuillez réessayer dans quelques instants.", status: 500, }; } return { data: { id: data.id, tache: data.tache as Tache, mode: data.mode as Mode, created_at: data.created_at, sujet, }, }; } // Sprint 3.7 — liste paginée des productions de l'utilisateur connecté. // Renvoie uniquement les champs utiles à l'affichage en liste (pas de contenu, // rapport, exercices, modele — trop lourds). export interface ListOptions { page: number; limit: number; } export interface ListItem { id: string; tache: Tache; mode: Mode; score: number | null; nclc: number | null; nclc_cible: 9 | 10 | null; created_at: string; } export interface ListResult { data: ListItem[]; pagination: { page: number; limit: number; total: number; }; } type ListError = ControllerError; export async function list( options: ListOptions, profile: AuthProfile, ): Promise<{ data: ListResult } | ListError> { const { page, limit } = options; const offset = (page - 1) * limit; const { data, error, count } = await supabase .from("productions") .select("id, tache, mode, score, nclc, nclc_cible, created_at", { count: "exact", }) .eq("user_id", profile.id) .order("created_at", { ascending: false }) .range(offset, offset + limit - 1); if (error) { return { error: true, code: "INTERNAL_ERROR", message: "Impossible de charger les simulations.", status: 500, }; } const items: ListItem[] = (data ?? []).map((row) => ({ id: row.id as string, tache: row.tache as Tache, mode: row.mode as Mode, score: (row.score as number | null) ?? null, nclc: (row.nclc as number | null) ?? null, nclc_cible: row.nclc_cible === 9 || row.nclc_cible === 10 ? (row.nclc_cible as 9 | 10) : null, created_at: row.created_at as string, })); return { data: { data: items, pagination: { page, limit, total: count ?? 0 }, }, }; } // Sprint 3.6a — structure enrichie (revelation, diagnostic, conseil_nclc, // erreurs_codes) + statuts des jobs asynchrones (modele, exercices). // // FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée). // Le frontend distingue : // - rapport !== null → RapportPage affiche la correction // - rapport === null → SimulationFlowProvider restaure la session (resume) export interface GetByIdResult { simulation_id: string; tache: Tache; mode: Mode; created_at: string; contenu: string | null; sujet: SujetData | null; rapport: CorrectionRapport | null; nclc_cible: 9 | 10 | null; exercices: ExerciceItem[] | null; exercices_status: JobStatus; modele: ProductionModele | null; modele_status: JobStatus; } type ControllerError = { error: true; code: string; message: string; status: number; }; export async function getById( id: string, profile: AuthProfile, ): Promise<{ data: GetByIdResult } | ControllerError> { const { data, error } = await supabase .from("productions") .select( "id, user_id, tache, mode, contenu, sujet_id, rapport, created_at, nclc_cible, exercices, exercices_status, modele, modele_status", ) .eq("id", id) .single(); if (error || !data) { return { error: true, code: "SIMULATION_NOT_FOUND", message: "Simulation introuvable.", status: 404, }; } if (data.user_id !== profile.id) { return { error: true, code: "AUTH_REQUIRED", message: "Cette simulation ne vous appartient pas.", status: 401, }; } // Charger le sujet si présent (FTD-21 — restore complet de la session). let sujet: SujetData | null = null; if (data.sujet_id) { const { data: sujetRow } = await supabase .from("sujets") .select( "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte", ) .eq("id", data.sujet_id) .single(); if (sujetRow) sujet = sujetRow as SujetData; } // Garde JSONB : supabase-js retourne les colonnes JSONB déjà parsées (objet) // mais on tolère le cas string au cas où le payload aurait été ré-encodé // (ex. cache, réponse stockée en TEXT par migration manuelle). const parseJsonb = (field: unknown): T | null => { if (field === null || field === undefined) return null; if (typeof field === "string") { try { return JSON.parse(field) as T; } catch { return null; } } return field as T; }; const rapport = parseJsonb(data.rapport); const exercicesParsed = parseJsonb(data.exercices); const exercices = Array.isArray(exercicesParsed) ? exercicesParsed : null; const modeleParsed = parseJsonb(data.modele); const modele = modeleParsed && typeof modeleParsed === "object" ? modeleParsed : null; const exercicesStatus = (data.exercices_status as JobStatus | null) ?? "pending"; const modeleStatus = (data.modele_status as JobStatus | null) ?? "pending"; const nclcCibleRaw = data.nclc_cible; const nclcCible: 9 | 10 | null = nclcCibleRaw === 9 || nclcCibleRaw === 10 ? nclcCibleRaw : null; return { data: { simulation_id: data.id, tache: data.tache as Tache, mode: data.mode as Mode, created_at: data.created_at, contenu: data.contenu ?? null, sujet, rapport, nclc_cible: nclcCible, exercices, exercices_status: exercicesStatus, modele, modele_status: modeleStatus, }, }; } /** * FTD-21 — autosave du contenu d'une simulation en cours. * Refuse si la simulation est déjà corrigée (rapport !== null). */ export async function autosaveContenu( id: string, userId: string, contenu: string, ): Promise<{ data: { ok: true } } | ControllerError> { if (contenu.length > 5000) { return { error: true, code: "VALIDATION_ERROR", message: "Le texte ne doit pas dépasser 5 000 caractères.", status: 400, }; } const { data: prod, error } = await supabase .from("productions") .select("user_id, rapport") .eq("id", id) .single(); if (error || !prod) { return { error: true, code: "SIMULATION_NOT_FOUND", message: "Simulation introuvable.", status: 404, }; } if (prod.user_id !== userId) { return { error: true, code: "AUTH_REQUIRED", message: "Cette simulation ne vous appartient pas.", status: 401, }; } if (prod.rapport !== null) { return { error: true, code: "VALIDATION_ERROR", message: "Cette simulation a déjà été corrigée.", status: 400, }; } const { error: updateError } = await supabase .from("productions") .update({ contenu }) .eq("id", id); if (updateError) { return { error: true, code: "INTERNAL_ERROR", message: "Sauvegarde impossible. Réessayez dans quelques instants.", status: 500, }; } return { data: { ok: true } }; } /** * FTD-21 — met à jour le sujet d'une simulation en cours. * Vérifie que le sujet existe et que la simulation n'est pas corrigée. */ export async function updateSujet( id: string, userId: string, sujetId: string, ): Promise<{ data: { sujet: SujetData } } | ControllerError> { const { data: sujetRow, error: sujetError } = await supabase .from("sujets") .select( "id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte", ) .eq("id", sujetId) .single(); if (sujetError || !sujetRow) { return { error: true, code: "SUJET_NOT_FOUND", message: "Sujet introuvable.", status: 404, }; } const { data: prod, error } = await supabase .from("productions") .select("user_id, rapport") .eq("id", id) .single(); if (error || !prod) { return { error: true, code: "SIMULATION_NOT_FOUND", message: "Simulation introuvable.", status: 404, }; } if (prod.user_id !== userId) { return { error: true, code: "AUTH_REQUIRED", message: "Cette simulation ne vous appartient pas.", status: 401, }; } if (prod.rapport !== null) { return { error: true, code: "VALIDATION_ERROR", message: "Cette simulation a déjà été corrigée.", status: 400, }; } const { error: updateError } = await supabase .from("productions") .update({ sujet_id: sujetId }) .eq("id", id); if (updateError) { return { error: true, code: "INTERNAL_ERROR", message: "Mise à jour impossible. Réessayez dans quelques instants.", status: 500, }; } return { data: { sujet: sujetRow as SujetData } }; }