Compare commits

..

3 Commits

Author SHA1 Message Date
anys a19a1e6c13 test(api): remove enseignements unit tests
Unit tests removed as they only used mocks without real value.
2026-04-22 17:13:14 +00:00
anys 2739a01ab5 fix(api): align enseignements route with Fresh file routing
- Replace flat file `[idProf]_[idModule]_[idPromo].ts`
  with nested structure `[idProf]/[idModule]/[idPromo].ts`
- Ensures URL matches `/enseignements/{idProf}/{idModule}/{idPromo}`
2026-04-22 17:13:14 +00:00
anys f3c1f10999 feat(api): implement enseignements CRUD endpoints
Add CRUD API for enseignements (prof-module-promo associations):

- POST /enseignements: Create with validation (201/409)
- GET /enseignements/{idProf}/{idModule}/{idPromo}: Read by composite
  key (200/404)
- DELETE /enseignements/{idProf}/{idModule}/{idPromo}: Delete by
  composite key (204/404)

Access control: Employee-only (403 Forbidden)
Tests: 7 unit tests added

Note: RBAC implementation pending (current access control is temporary)
2026-04-22 17:13:14 +00:00
3 changed files with 145 additions and 108 deletions
-108
View File
@@ -1,108 +0,0 @@
import { FreshContext } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import {
enseignements,
rolePermissions,
users,
} from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, eq } from "npm:drizzle-orm@0.45.2";
type RuleFn = (
req: Request,
ctx: FreshContext<AuthenticatedState>,
) => Promise<boolean> | boolean;
async function hasPermission(
uid: string,
permission: string,
): Promise<boolean> {
const [user] = await db.select().from(users).where(eq(users.id, uid));
if (!user || user.idRole === null) return false;
const [rp] = await db.select().from(rolePermissions).where(
and(
eq(rolePermissions.idRole, user.idRole!),
eq(rolePermissions.idPermission, permission),
),
);
return !!rp;
}
function parseNumEtud(uid: string): number {
return parseInt(uid.slice(1));
}
const rules = {
student_read: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "student_read"),
student_write: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "student_write"),
note_read: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "note_read"),
note_write: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "note_write"),
module_read: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "module_read"),
module_write: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "module_write"),
user_read: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "user_read"),
user_write: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "user_write"),
role_write: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
hasPermission(ctx.state.session.uid, "role_write"),
// Contextual rules — student accessing their own data
own_student: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
parseNumEtud(ctx.state.session.uid) === Number(ctx.params.numEtud),
own_note: (_req: Request, ctx: FreshContext<AuthenticatedState>) =>
parseNumEtud(ctx.state.session.uid) === Number(ctx.params.numEtud),
// Contextual rule — teacher accessing notes for a module they teach
own_teaching_note: async (
_req: Request,
ctx: FreshContext<AuthenticatedState>,
) => {
const [row] = await db.select().from(enseignements).where(
and(
eq(enseignements.idProf, ctx.state.session.uid),
eq(enseignements.idModule, ctx.params.idModule),
),
);
return !!row;
},
};
export type RuleName = keyof typeof rules;
type HandlerFn = (
req: Request,
ctx: FreshContext<AuthenticatedState>,
) => Promise<Response>;
/**
* Wraps a route handler with permission checks.
* Access is granted if ANY of the provided rules passes (OR logic).
* Returns 403 if none pass.
*
* @example
* export const handler: Handlers<null, AuthenticatedState> = {
* GET: withRules(["note_read", "own_note"])(async (req, ctx) => {
* // ...
* }),
* };
*/
export function withRules(ruleNames: RuleName[]) {
return (handler: HandlerFn): HandlerFn => {
return async (req, ctx) => {
const results = await Promise.all(
ruleNames.map((name) => rules[name](req, ctx)),
);
if (!results.some(Boolean)) {
return new Response(null, { status: 403 });
}
return handler(req, ctx);
};
};
}
+70
View File
@@ -0,0 +1,70 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { enseignements } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, 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 FORBIDDEN = new Response(null, { status: 403 });
const CONFLICT = new Response(
JSON.stringify({ error: "Cet enseignement existe déjà." }),
{ status: 409, headers: { "content-type": "application/json" } },
);
export const handler: Handlers<null, AuthenticatedState> = {
// #29 POST /enseignements
async POST(
request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
}
const body: {
idProf: string;
idModule: string;
idPromo: string;
} = await request.json();
if (!body.idProf || !body.idModule || !body.idPromo) {
return new Response(null, { status: 400 });
}
// Check if enseignement already exists
const existing = await db
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, body.idProf),
eq(enseignements.idModule, body.idModule),
eq(enseignements.idPromo, body.idPromo),
),
)
.then((rows) => rows[0] ?? null);
if (existing) {
return CONFLICT;
}
const [created] = await db
.insert(enseignements)
.values({
idProf: body.idProf,
idModule: body.idModule,
idPromo: body.idPromo,
})
.returning();
return new Response(JSON.stringify(created), {
status: 201,
headers: { "content-type": "application/json" },
});
},
};
@@ -0,0 +1,75 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { db } from "$root/databases/db.ts";
import { enseignements } from "$root/databases/schema.ts";
import { AuthenticatedState } from "$root/defaults/interfaces.ts";
import { and, 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 FORBIDDEN = new Response(null, { status: 403 });
export const handler: Handlers<null, AuthenticatedState> = {
// #30 GET /enseignements/{idProf}/{idModule}/{idPromo}
async GET(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
}
const idProf = context.params.idProf;
const idModule = context.params.idModule;
const idPromo = context.params.idPromo;
const enseignement = await db
.select()
.from(enseignements)
.where(
and(
eq(enseignements.idProf, idProf),
eq(enseignements.idModule, idModule),
eq(enseignements.idPromo, idPromo),
),
)
.then((rows) => rows[0] ?? null);
if (!enseignement) return NOT_FOUND;
return new Response(JSON.stringify(enseignement), {
headers: { "content-type": "application/json" },
});
},
// #31 DELETE /enseignements/{idProf}/{idModule}/{idPromo}
async DELETE(
_request: Request,
context: FreshContext<AuthenticatedState>,
): Promise<Response> {
if (context.state.session.eduPersonPrimaryAffiliation !== "employee") {
return FORBIDDEN;
}
const idProf = context.params.idProf;
const idModule = context.params.idModule;
const idPromo = context.params.idPromo;
const [deleted] = await db
.delete(enseignements)
.where(
and(
eq(enseignements.idProf, idProf),
eq(enseignements.idModule, idModule),
eq(enseignements.idPromo, idPromo),
),
)
.returning();
if (!deleted) return NOT_FOUND;
return new Response(null, { status: 204 });
},
};