Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/prisma/migrations/20260704110914_add_folders/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "Subject" ADD COLUMN "folderId" INTEGER;

-- CreateTable
CREATE TABLE "Folder" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "Subject" ADD CONSTRAINT "Subject_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
9 changes: 9 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ model Manta {
event Event @relation(fields: [eventId], references: [id])
}

model Folder {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
subjects Subject[]
}

model Subject {
id Int @id @default(autoincrement())
name String
Expand All @@ -53,6 +60,8 @@ model Subject {
proposed Boolean @default(false)
rejected Boolean @default(false)
rejectionReason String?
folderId Int?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Expand Down
18 changes: 18 additions & 0 deletions backend/src/models/folder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { prisma } from "../utils/prisma";

export const folderModel = {
findAll: () =>
prisma.folder.findMany({
orderBy: { name: "asc" },
include: { _count: { select: { subjects: true } } },
}),

create: (name: string) =>
prisma.folder.create({ data: { name } }),

rename: (id: number, name: string) =>
prisma.folder.update({ where: { id }, data: { name } }),

delete: (id: number) =>
prisma.folder.delete({ where: { id } }),
};
8 changes: 7 additions & 1 deletion backend/src/models/subject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const subjectModel = {
setVisible: (id: number, visible: boolean) =>
prisma.subject.update({ where: { id }, data: { visible } }),

setAllVisible: (visible: boolean, folderId: number | null) =>
prisma.subject.updateMany({ where: { proposed: false, rejected: false, folderId }, data: { visible } }),

setPinned: (id: number, pinned: boolean) =>
prisma.subject.update({ where: { id }, data: { pinned } }),

Expand All @@ -35,9 +38,12 @@ export const subjectModel = {
reject: (id: number, reason: string) =>
prisma.subject.update({ where: { id }, data: { proposed: false, rejected: true, rejectionReason: reason } }),

update: (id: number, data: { name: string; description: string; difficulty: import("@prisma/client").Difficulty; tags: string[]; files: string[]; urls: string[] }) =>
update: (id: number, data: { name: string; description: string; difficulty: import("@prisma/client").Difficulty; tags: string[]; files: string[]; urls: string[]; folderId?: number | null }) =>
prisma.subject.update({ where: { id }, data }),

deleteById: (id: number) =>
prisma.subject.delete({ where: { id } }),

countByFile: (filename: string) =>
prisma.subject.count({ where: { files: { has: filename } } }),
};
53 changes: 53 additions & 0 deletions backend/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,18 @@ import Elysia, { t } from "elysia";
import { subjectService } from "../services/subject";
import { eventService } from "../services/event";
import { whitelistModel } from "../models/whitelist";
import { folderModel } from "../models/folder";
import { verifyAdminToken } from "../lib/adminJwt";

// formData envoie folderId en str : "" = racine (null), absent = ne pas modif
function parseFolderId(raw: unknown): number | null | undefined {
if (raw === undefined) return undefined;
const s = String(raw);
if (s === "") return null;
const n = Number(s);
return isNaN(n) ? undefined : n;
}

export const adminRoutes = new Elysia({ prefix: "/admin" })
.onBeforeHandle(async ({ headers }) => {
const token = headers["authorization"]?.replace("Bearer ", "");
Expand All @@ -26,6 +36,7 @@ export const adminRoutes = new Elysia({ prefix: "/admin" })
tags: String(data["tags"] ?? ""),
urls: data["urls"] ? String(data["urls"]) : undefined,
newFiles,
folderId: parseFolderId(data["folderId"]),
});
return { success: true, subject };
},
Expand All @@ -40,10 +51,35 @@ export const adminRoutes = new Elysia({ prefix: "/admin" })
]),
tags: t.Optional(t.String()),
urls: t.Optional(t.String()),
folderId: t.Optional(t.String()),
file: t.Optional(t.Any()),
}),
}
)
.get("/folders", async () => {
const folders = await folderModel.findAll();
return { folders };
})
.post("/folders", async ({ body }) => {
const folder = await folderModel.create(body.name.trim());
return { success: true, folder };
}, {
body: t.Object({ name: t.String({ minLength: 1 }) }),
})
.patch("/folders/:id", async ({ params, body, set }) => {
const id = Number(params.id);
if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; }
const folder = await folderModel.rename(id, body.name.trim());
return { success: true, folder };
}, {
body: t.Object({ name: t.String({ minLength: 1 }) }),
})
.delete("/folders/:id", async ({ params, set }) => {
const id = Number(params.id);
if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; }
await folderModel.delete(id);
return { success: true };
})
.get("/subjects", async () => {
const subjects = await subjectService.getAll();
return { subjects };
Expand All @@ -67,6 +103,7 @@ export const adminRoutes = new Elysia({ prefix: "/admin" })
urls: data["urls"] ? String(data["urls"]) : undefined,
newFiles,
existingFiles,
folderId: parseFolderId(data["folderId"]),
});
return { success: true, subject };
}, {
Expand All @@ -77,9 +114,19 @@ export const adminRoutes = new Elysia({ prefix: "/admin" })
tags: t.Optional(t.String()),
urls: t.Optional(t.String()),
existingFiles: t.Optional(t.String()),
folderId: t.Optional(t.String()),
file: t.Optional(t.Any()),
}),
})
.patch("/subjects/visible-all", async ({ body }) => {
await subjectService.setAllVisible(body.visible, body.folderId);
return { success: true };
}, {
body: t.Object({
visible: t.Boolean(),
folderId: t.Union([t.Number(), t.Null()]),
}),
})
.patch("/subjects/:id/visible", async ({ params, body, set }) => {
const id = Number(params.id);
if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; }
Expand All @@ -96,6 +143,12 @@ export const adminRoutes = new Elysia({ prefix: "/admin" })
}, {
body: t.Object({ pinned: t.Boolean() }),
})
.delete("/subjects/:id", async ({ params, set }) => {
const id = Number(params.id);
if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; }
await subjectService.delete(id);
return { success: true };
})
.post("/subjects/:id/approve", async ({ params, set }) => {
const id = Number(params.id);
if (isNaN(id)) { set.status = 400; return { message: "Invalid id" }; }
Expand Down
5 changes: 5 additions & 0 deletions backend/src/routes/manta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Elysia, { t } from "elysia";
import { mantaAuth } from "../middlewares/mantaAuth";
import { subjectService } from "../services/subject";
import { eventService } from "../services/event";
import { folderModel } from "../models/folder";

const DIFF_LITERALS = t.Union([
t.Literal("Débutant"),
Expand All @@ -15,6 +16,10 @@ export const mantaRoutes = new Elysia({ prefix: "/manta" })
const subjects = await subjectService.getAll();
return { subjects };
})
.get("/folders", async () => {
const folders = await folderModel.findAll();
return { folders };
})
.get("/subjects/proposed", async () => {
const subjects = await subjectService.getProposed();
return { subjects };
Expand Down
21 changes: 18 additions & 3 deletions backend/src/services/subject.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Difficulty } from "@prisma/client";
import { subjectModel } from "../models/subject";
import type { SubjectServiceInput, SubjectProposeInput } from "../types/subject";
import { writeFile } from "fs/promises";
import { writeFile, unlink } from "fs/promises";
import { join } from "path";

const DIFFICULTY_MAP: Record<string, Difficulty> = {
Expand Down Expand Up @@ -43,7 +43,7 @@ export const subjectService = {
const urls = (data.urls ?? "").split("\n").map(u => u.trim()).filter(Boolean);
const files = await saveFiles(data.newFiles ?? []);

return subjectModel.create({ name: data.name, description: data.description, difficulty, tags, files, urls });
return subjectModel.create({ name: data.name, description: data.description, difficulty, tags, files, urls, folderId: data.folderId ?? null });
},

getVisible: async () => {
Expand Down Expand Up @@ -100,17 +100,32 @@ export const subjectService = {
const newFileNames = await saveFiles(data.newFiles ?? []);
const files = [...(data.existingFiles ?? []), ...newFileNames];

return subjectModel.update(id, { name: data.name, description: data.description, difficulty, tags, files, urls });
return subjectModel.update(id, { name: data.name, description: data.description, difficulty, tags, files, urls, folderId: data.folderId });
},

setVisible: (id: number, visible: boolean) =>
subjectModel.setVisible(id, visible),

setAllVisible: (visible: boolean, folderId: number | null) =>
subjectModel.setAllVisible(visible, folderId),

setPinned: (id: number, pinned: boolean) =>
subjectModel.setPinned(id, pinned),

getPinned: async () => {
const rows = await subjectModel.findPinned();
return rows.map(s => ({ ...s, difficulty: DIFFICULTY_LABEL[s.difficulty] }));
},

delete: async (id: number) => {
const subject = await subjectModel.findById(id);
if (!subject) throw new Error("Subject not found");
await subjectModel.deleteById(id);
// supprime les fichiers uploadés qu'aucun autre sujet ne référence
for (const file of subject.files) {
const stillUsed = await subjectModel.countByFile(file);
if (stillUsed === 0)
await unlink(join(UPLOADS_DIR, file)).catch(() => {});
}
},
};
2 changes: 2 additions & 0 deletions backend/src/types/subject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface SubjectCreateData {
files: string[];
urls: string[];
proposed?: boolean;
folderId?: number | null;
}

export interface SubjectServiceInput {
Expand All @@ -17,6 +18,7 @@ export interface SubjectServiceInput {
tags: string;
urls?: string;
newFiles?: File[];
folderId?: number | null;
}

export interface SubjectProposeInput {
Expand Down
Loading
Loading