expria-backend/src/controllers/simulationController.ts

483 lines
12 KiB
TypeScript

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 = <T>(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<CorrectionRapport>(data.rapport);
const exercicesParsed = parseJsonb<ExerciceItem[]>(data.exercices);
const exercices = Array.isArray(exercicesParsed) ? exercicesParsed : null;
const modeleParsed = parseJsonb<ProductionModele>(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 } };
}