feat: cascade deletes, student notes, import popups, module reorganization

- Cascade delete on all entities (student, module, UE, user, role, promotion)
- Fix Response body reuse bug (factory functions instead of constants)
- Student note viewing via CAS uid (strip non-digit prefix)
- Fix middleware page visibility for students in LOCAL mode
- Import result popup component (shared across all import pages)
- Fix student import to use numEtud from Excel
- Bulk student selection with promo change and delete
- Move UE/UE-Module API and pages from notes to admin module
- Move promotions page from students to admin module
- Multi-year maquette import with per-year promo selection
- Inline promo creation in maquette import
- Static Excel templates (students, notes, maquette)
- Fix XLSX export using blob download instead of writeFile
- Allow students to read modules list (GET /modules)
This commit is contained in:
2026-04-30 13:47:16 +02:00
parent 04be659d6b
commit 6c38cd0019
51 changed files with 3022 additions and 437 deletions
@@ -1,15 +1,25 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { promotions } from "$root/databases/schema.ts";
import {
ajustements,
enseignements,
modules,
notes,
promotions,
students,
ueModules,
ues,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #15 GET /promotions/{idPromo}
@@ -18,7 +28,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const promo = await db
@@ -27,7 +37,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(promotions.id, context.params.idPromo))
.then((rows) => rows[0] ?? null);
if (!promo) return NOT_FOUND;
if (!promo) return NOT_FOUND();
return new Response(JSON.stringify(promo), {
headers: { "content-type": "application/json" },
@@ -40,7 +50,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const body: { annee: string } = await request.json();
@@ -51,7 +61,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(promotions.id, context.params.idPromo))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -59,20 +69,104 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #17 DELETE /promotions/{idPromo}
// Blocked if students are still assigned (409).
// Cascade: deletes linked ue_modules, enseignements, and orphaned
// modules (+ their notes) & UEs (+ their ajustements).
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const [deleted] = await db
.delete(promotions)
.where(eq(promotions.id, context.params.idPromo))
.returning();
const idPromo = context.params.idPromo;
if (!deleted) return NOT_FOUND;
const promo = await db
.select()
.from(promotions)
.where(eq(promotions.id, idPromo))
.then((r) => r[0] ?? null);
if (!promo) return NOT_FOUND();
// Block deletion if students are still assigned
const assignedStudents = await db
.select()
.from(students)
.where(eq(students.idPromo, idPromo))
.then((r) => r.length);
if (assignedStudents > 0) {
return new Response(
JSON.stringify({
error:
`Impossible de supprimer : ${assignedStudents} étudiant(s) encore assigné(s) à cette promotion`,
}),
{ status: 409, headers: { "content-type": "application/json" } },
);
}
await db.transaction(async (tx) => {
// Collect linked module IDs and UE IDs before deleting junction rows
const linkedUeModules = await tx
.select({ idModule: ueModules.idModule, idUE: ueModules.idUE })
.from(ueModules)
.where(eq(ueModules.idPromo, idPromo));
const linkedEns = await tx
.select({ idModule: enseignements.idModule })
.from(enseignements)
.where(eq(enseignements.idPromo, idPromo));
const moduleIds = [
...new Set([
...linkedUeModules.map((um) => um.idModule),
...linkedEns.map((e) => e.idModule),
]),
];
const ueIds = [...new Set(linkedUeModules.map((um) => um.idUE))];
// Delete junction rows that directly reference this promo
await tx.delete(ueModules).where(eq(ueModules.idPromo, idPromo));
await tx.delete(enseignements).where(eq(enseignements.idPromo, idPromo));
// Delete orphaned modules (not used by another promo) and their notes
for (const modId of moduleIds) {
const stillInUeModules = await tx
.select()
.from(ueModules)
.where(eq(ueModules.idModule, modId))
.then((r) => r.length > 0);
const stillInEns = await tx
.select()
.from(enseignements)
.where(eq(enseignements.idModule, modId))
.then((r) => r.length > 0);
if (!stillInUeModules && !stillInEns) {
await tx.delete(notes).where(eq(notes.idModule, modId));
await tx.delete(modules).where(eq(modules.id, modId));
}
}
// Delete orphaned UEs (not used by another promo) and their ajustements
for (const ueId of ueIds) {
const stillUsed = await tx
.select()
.from(ueModules)
.where(eq(ueModules.idUE, ueId))
.then((r) => r.length > 0);
if (!stillUsed) {
await tx.delete(ajustements).where(eq(ajustements.idUE, ueId));
await tx.delete(ues).where(eq(ues.id, ueId));
}
}
// Delete the promotion
await tx.delete(promotions).where(eq(promotions.id, idPromo));
});
return new Response(null, { status: 204 });
},
+14 -2
View File
@@ -44,13 +44,25 @@ export const handler: Handlers<null, AuthenticatedState> = {
idPromo: string;
} = await request.json();
if (!body.nom || !body.prenom || !body.idPromo) {
if (!body.nom || !body.prenom) {
return new Response(null, { status: 400 });
}
const values: {
numEtud?: number;
nom: string;
prenom: string;
idPromo?: string;
} = {
nom: body.nom,
prenom: body.prenom,
};
if (body.numEtud) values.numEtud = body.numEtud;
if (body.idPromo) values.idPromo = body.idPromo;
const [created] = await db
.insert(students)
.values({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
.values(values)
.returning();
return new Response(JSON.stringify(created), {
@@ -1,15 +1,21 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { students } from "$root/databases/schema.ts";
import {
ajustements,
mobility,
notes,
students,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { eq } from "npm:drizzle-orm@0.45.2";
const NOT_FOUND = new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const NOT_FOUND = () =>
new Response(
JSON.stringify({ error: "Ressource introuvable" }),
{ status: 404, headers: { "content-type": "application/json" } },
);
const FORBIDDEN = new Response(null, { status: 403 });
const FORBIDDEN = () => new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #10 GET /students/{numEtud}
@@ -18,7 +24,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
@@ -28,7 +34,7 @@ export const handler: Handlers<null, AuthenticatedState> = {
.where(eq(students.numEtud, numEtud))
.then((rows) => rows[0] ?? null);
if (!student) return NOT_FOUND;
if (!student) return NOT_FOUND();
return new Response(JSON.stringify(student), {
headers: { "content-type": "application/json" },
@@ -41,20 +47,32 @@ export const handler: Handlers<null, AuthenticatedState> = {
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const body: { nom: string; prenom: string; idPromo: string } = await request
.json();
const body: { nom?: string; prenom?: string; idPromo?: string } =
await request.json();
const set: { nom?: string; prenom?: string; idPromo?: string } = {};
if (body.nom !== undefined) set.nom = body.nom;
if (body.prenom !== undefined) set.prenom = body.prenom;
if (body.idPromo !== undefined) set.idPromo = body.idPromo;
if (Object.keys(set).length === 0) {
return new Response(
JSON.stringify({ error: "Au moins un champ requis" }),
{ status: 400, headers: { "content-type": "application/json" } },
);
}
const [updated] = await db
.update(students)
.set({ nom: body.nom, prenom: body.prenom, idPromo: body.idPromo })
.set(set)
.where(eq(students.numEtud, numEtud))
.returning();
if (!updated) return NOT_FOUND;
if (!updated) return NOT_FOUND();
return new Response(JSON.stringify(updated), {
headers: { "content-type": "application/json" },
@@ -62,21 +80,31 @@ export const handler: Handlers<null, AuthenticatedState> = {
},
// #12 DELETE /students/{numEtud}
// Cascade: deletes notes, ajustements, mobility for this student.
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
return FORBIDDEN();
}
const numEtud = Number(context.params.numEtud);
const [deleted] = await db
.delete(students)
.where(eq(students.numEtud, numEtud))
.returning();
if (!deleted) return NOT_FOUND;
const student = await db
.select()
.from(students)
.where(eq(students.numEtud, numEtud))
.then((r) => r[0] ?? null);
if (!student) return NOT_FOUND();
await db.transaction(async (tx) => {
await tx.delete(notes).where(eq(notes.numEtud, numEtud));
await tx.delete(ajustements).where(eq(ajustements.numEtud, numEtud));
await tx.delete(mobility).where(eq(mobility.studentId, numEtud));
await tx.delete(students).where(eq(students.numEtud, numEtud));
});
return new Response(null, { status: 204 });
},