483 lines
12 KiB
TypeScript
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 } };
|
|
}
|