From 4ff76fdf6fbda8a2370116e903d9e8f59c6c2f7c Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Mon, 27 Jan 2025 10:39:42 +0100 Subject: [PATCH 1/3] Added hidden admin only page prop effect --- defaults/interfaces.ts | 3 ++- deno.json | 1 + fresh.config.ts | 2 ++ fresh.gen.ts | 2 ++ routes/(apps)/_layout.tsx | 11 ++++------- routes/(apps)/_middleware.ts | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 routes/(apps)/_middleware.ts diff --git a/defaults/interfaces.ts b/defaults/interfaces.ts index a5a6268..f385846 100644 --- a/defaults/interfaces.ts +++ b/defaults/interfaces.ts @@ -1,9 +1,10 @@ import { type RegularTagNode, type TextNode } from "@melvdouc/xml-parser"; import { AsyncRoute } from "$fresh/src/server/types.ts"; -interface AuthenticatedState { +export interface AuthenticatedState { isAuthenticated: true; session: CasContent; + availablePages: Record; } interface UnauthenticatedState { diff --git a/deno.json b/deno.json index a7635bb..45c5870 100644 --- a/deno.json +++ b/deno.json @@ -29,6 +29,7 @@ "@popov/jwt": "jsr:@popov/jwt@^1.0.1", "@psych/sheet": "jsr:@psych/sheet@^1.0.6", "@std/cli": "jsr:@std/cli@^1.0.10", + "@std/dotenv": "jsr:@std/dotenv@^0.225.3", "preact": "https://esm.sh/preact@10.22.0", "preact/": "https://esm.sh/preact@10.22.0/", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", diff --git a/fresh.config.ts b/fresh.config.ts index 5881650..ebe1722 100644 --- a/fresh.config.ts +++ b/fresh.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "$fresh/server.ts"; import ensureDatabases from "$root/databases/ensure.ts"; +import { load } from "@std/dotenv"; +await load({ envPath: "./.env.development.local", export: true }); await ensureDatabases(); export default defineConfig({ server: { diff --git a/fresh.gen.ts b/fresh.gen.ts index 2b0c7d3..2beb32c 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -3,6 +3,7 @@ // This file is automatically updated during development when running `dev.ts`. import * as $_apps_layout from "./routes/(apps)/_layout.tsx"; +import * as $_apps_middleware from "./routes/(apps)/_middleware.ts"; import * as $_apps_mobility_api_insert_mobility from "./routes/(apps)/mobility/api/insert_mobility.ts"; import * as $_apps_mobility_index from "./routes/(apps)/mobility/index.tsx"; import * as $_apps_mobility_partials_admin_edit_mobility from "./routes/(apps)/mobility/partials/(admin)/edit_mobility.tsx"; @@ -39,6 +40,7 @@ import type { Manifest } from "$fresh/server.ts"; const manifest = { routes: { "./routes/(apps)/_layout.tsx": $_apps_layout, + "./routes/(apps)/_middleware.ts": $_apps_middleware, "./routes/(apps)/mobility/api/insert_mobility.ts": $_apps_mobility_api_insert_mobility, "./routes/(apps)/mobility/index.tsx": $_apps_mobility_index, diff --git a/routes/(apps)/_layout.tsx b/routes/(apps)/_layout.tsx index ee3b43e..9694880 100644 --- a/routes/(apps)/_layout.tsx +++ b/routes/(apps)/_layout.tsx @@ -1,22 +1,19 @@ import { FreshContext } from "$fresh/server.ts"; import { Partial } from "$fresh/runtime.ts"; -import { State } from "$root/defaults/interfaces.ts"; -import { AppProperties } from "$root/defaults/interfaces.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; import Navbar from "$root/routes/(_islands)/Navbar.tsx"; +// deno-lint-ignore require-await export default async function AppLayout( request: Request, - context: FreshContext, + context: FreshContext, ) { const pathname = new URL(request.url).pathname; const currentApp = pathname.split("/")[1]; - const properties: AppProperties = (await import( - `./${currentApp}/(_props)/props.ts` - )).default; return (
- +
diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts new file mode 100644 index 0000000..abb11cc --- /dev/null +++ b/routes/(apps)/_middleware.ts @@ -0,0 +1,36 @@ +import { FreshContext, MiddlewareHandler } from "$fresh/server.ts"; +import { + AppProperties, + AuthenticatedState, +} from "$root/defaults/interfaces.ts"; + +export const handler: MiddlewareHandler[] = [ + /** + * Check if user is authenticated and add session to context accordingly. + * @param request The HTTP incomming request. + * @param context The Fresh context object with custom `AuthenticatedState`. + * @returns The response from the next middleware. + */ + async function checkAuthentication( + request: Request, + context: FreshContext, + ): Promise { + const pathname = new URL(request.url).pathname; + const currentApp = pathname.split("/")[1]; + const properties: AppProperties = (await import( + `./${currentApp}/(_props)/props.ts` + )).default; + + context.state.availablePages = properties.pages; + if ( + context.state.session.eduPersonPrimaryAffiliation == "student" && + Deno.env.get("LOCAL") != "true" + ) { + properties.adminOnly.forEach((page) => + delete context.state.availablePages[page] + ); + } + + return await context.next(); + }, +]; From e88045c952001ed78f5acf818633c0db56475623 Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Mon, 27 Jan 2025 13:11:13 +0100 Subject: [PATCH 2/3] Refactored students --- databases/init/students.sql | 17 +- fresh.gen.ts | 10 +- routes/(apps)/_middleware.ts | 4 +- .../students/(_islands)/ConsultStudents.tsx | 2 +- .../students/(_islands)/UploadStudents.tsx | 2 +- routes/(apps)/students/(_props)/props.ts | 1 - routes/(apps)/students/api/insert_students.ts | 84 ---------- routes/(apps)/students/api/students.ts | 155 ++++++++++++++++++ routes/(apps)/students/api/types.d.ts | 13 ++ .../students/partials/(admin)/consult.tsx | 3 +- .../students/partials/(admin)/upload.tsx | 3 +- routes/(apps)/students/partials/index.tsx | 2 +- routes/(apps)/students/partials/overview.tsx | 17 -- 13 files changed, 187 insertions(+), 126 deletions(-) delete mode 100644 routes/(apps)/students/api/insert_students.ts create mode 100644 routes/(apps)/students/api/students.ts create mode 100644 routes/(apps)/students/api/types.d.ts delete mode 100644 routes/(apps)/students/partials/overview.tsx diff --git a/databases/init/students.sql b/databases/init/students.sql index 4dbc7e8..b5a7878 100644 --- a/databases/init/students.sql +++ b/databases/init/students.sql @@ -1,15 +1,14 @@ create table promotions ( - id integer primary key autoincrement, - name text, - endyear integer, - current integer + id integer primary key autoincrement, + endyear integer, + current integer ); create table students ( - userId text primary key, - firstName text, - lastName text, - mail text, - promotionId integer, + userId text primary key, + firstName text, + lastName text, + mail text, + promotionId integer, foreign key(promotionId) references promotions(id) ); \ No newline at end of file diff --git a/fresh.gen.ts b/fresh.gen.ts index 2beb32c..3061dcd 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -13,12 +13,12 @@ import * as $_apps_notes_index from "./routes/(apps)/notes/index.tsx"; import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/partials/(admin)/courses.tsx"; import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; -import * as $_apps_students_api_insert_students from "./routes/(apps)/students/api/insert_students.ts"; +import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; +import * as $_apps_students_api_types_d from "./routes/(apps)/students/api/types.d.ts"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; -import * as $_apps_students_partials_overview from "./routes/(apps)/students/partials/overview.tsx"; import * as $_404 from "./routes/_404.tsx"; import * as $_app from "./routes/_app.tsx"; import * as $_middleware from "./routes/_middleware.ts"; @@ -55,8 +55,8 @@ const manifest = { $_apps_notes_partials_admin_courses, "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, - "./routes/(apps)/students/api/insert_students.ts": - $_apps_students_api_insert_students, + "./routes/(apps)/students/api/students.ts": $_apps_students_api_students, + "./routes/(apps)/students/api/types.d.ts": $_apps_students_api_types_d, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, @@ -64,8 +64,6 @@ const manifest = { $_apps_students_partials_admin_upload, "./routes/(apps)/students/partials/index.tsx": $_apps_students_partials_index, - "./routes/(apps)/students/partials/overview.tsx": - $_apps_students_partials_overview, "./routes/_404.tsx": $_404, "./routes/_app.tsx": $_app, "./routes/_middleware.ts": $_middleware, diff --git a/routes/(apps)/_middleware.ts b/routes/(apps)/_middleware.ts index abb11cc..f30f19f 100644 --- a/routes/(apps)/_middleware.ts +++ b/routes/(apps)/_middleware.ts @@ -6,12 +6,12 @@ import { export const handler: MiddlewareHandler[] = [ /** - * Check if user is authenticated and add session to context accordingly. + * Get all available pages for current user. * @param request The HTTP incomming request. * @param context The Fresh context object with custom `AuthenticatedState`. * @returns The response from the next middleware. */ - async function checkAuthentication( + async function getAllAvailablePages( request: Request, context: FreshContext, ): Promise { diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index 1f82580..835754f 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -22,7 +22,7 @@ export default function ConsultStudents() { useEffect(() => { const fetchData = async () => { try { - const response = await fetch("/students/api/insert_students"); + const response = await fetch("/students/api/students"); if (!response.ok) { throw new Error(`Error fetching data: ${response.statusText}`); } diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index 244e4d2..6ff1d5a 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -39,7 +39,7 @@ export default function UploadStudents() { console.log(`Data from sheet ${sheetName}:`, data); - const response = await fetch("/students/api/insert_students", { + const response = await fetch("/students/api/students", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ promoName: sheetName, data }), diff --git a/routes/(apps)/students/(_props)/props.ts b/routes/(apps)/students/(_props)/props.ts index 93a50a5..13bafe9 100644 --- a/routes/(apps)/students/(_props)/props.ts +++ b/routes/(apps)/students/(_props)/props.ts @@ -5,7 +5,6 @@ const properties: AppProperties = { icon: "badge", pages: { index: "Homepage", - overview: "Students overview", upload: "Upload students", consult: "Consult students", }, diff --git a/routes/(apps)/students/api/insert_students.ts b/routes/(apps)/students/api/insert_students.ts deleted file mode 100644 index f1c5020..0000000 --- a/routes/(apps)/students/api/insert_students.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Handlers } from "$fresh/server.ts"; -// import { Database } from "@db/sqlite"; -import connect from "$root/databases/connect.ts"; - -export const handler: Handlers = { - // deno-lint-ignore require-await - async GET() { - try { - using connection = connect("students"); - - const promotions = connection.database.prepare( - "select id, name from promotions", - ).all(); - - const students = connection.database - .prepare( - `select userId, firstName, lastName, mail, promotionId from students`, - ) - .all(); - - return new Response( - JSON.stringify({ promotions, students }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } catch (error) { - console.error("Error fetching data:", error); - return new Response("Failed to fetch data", { status: 500 }); - } - }, - - async POST(request) { - console.log("API /students/api/insert_students called"); - - try { - const body = await request.json(); - const { data, promoName } = body; - - console.log("Received data:", { promoName, data }); - - if (!promoName || !Array.isArray(data)) { - throw new Error("Invalid request body"); - } - - using connection = connect("students"); - - connection.database.prepare( - "INSERT OR IGNORE INTO promotions (name) VALUES (?)", - ).run(promoName); - - const promoIdRow: { id: number } = connection.database - .prepare("SELECT id FROM promotions WHERE name = ?") - .get(promoName)!; - const promoId = promoIdRow.id; - - console.log(`Promotion ID for "${promoName}":`, promoId); - - const insertQuery = connection.database.prepare( - `INSERT INTO students - (userId, firstName, lastName, mail, promotionId) - VALUES (?, ?, ?, ?, ?)`, - ); - - for (const student of data) { - console.log("Inserting student:", student); - insertQuery.run( - student.Identifiant, - student.Nom, - student["Prénom"], - student.Mail, - promoId, - ); - } - - console.log("All data inserted successfully"); - return new Response("Data inserted successfully", { status: 201 }); - } catch (error) { - console.error("Error inserting data:", error); - return new Response("Failed to insert data", { status: 500 }); - } - }, -}; diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts new file mode 100644 index 0000000..eb1dd2b --- /dev/null +++ b/routes/(apps)/students/api/students.ts @@ -0,0 +1,155 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import connect from "$root/databases/connect.ts"; +import { AuthenticatedState } from "$root/defaults/interfaces.ts"; +import { Database } from "@db/sqlite"; + +/** + * Gets itself from the database. + * @param database The database connection + * @param userId The user ID. + * @returns Itself from the database. + */ +function getItself( + database: Database, + userId: string, +): { student: Student | null; promo: Promotion | null } { + const studentQuery = "select * from students where userId = ?"; + const student: Student | undefined = database.prepare(studentQuery).get( + userId, + ); + + if (!student) { + return { student: null, promo: null }; + } + + const promoQuery = "select * from promotions where id = ?"; + const promo: Promotion | undefined = database.prepare(promoQuery).get( + student.promotionId, + ); + + return { student, promo: promo ?? null }; +} + +/** + * Gets itself from the database. + * @param database The database connection + * @param userId The user ID. + * @returns Itself from the database. + */ +function getAll( + database: Database, + userId: string, +): { student: Student | null; promo: Promotion | null } { + const studentQuery = "select * from students where userId = ?"; + const student: Student | undefined = database.prepare(studentQuery).get( + userId, + ); + + if (!student) { + return { student: null, promo: null }; + } + + const promoQuery = "select * from promotions where id = ?"; + const promo: Promotion | undefined = database.prepare(promoQuery).get( + student.promotionId, + ); + + return { student, promo: promo ?? null }; +} + + +export const handler: Handlers = { + /** + * The students the user can see. + * @param _request The HTTP request. + * @param _context The context with authenticated state. + * @returns All students our user can see. + */ + // deno-lint-ignore require-await + async GET( + _request: Request, + context: FreshContext, + ): Promise { + using connection = connect("students"); + const database = connection.database; + + if (context.state.session.eduPersonPrimaryAffiliation == "student") { + return new Response( + JSON.stringify({ + student: getItself(database, context.state.session.uid), + }), + { + headers: { + "content-type": "application/json", + }, + }, + ); + } + + const promotions = database.prepare("select id, name from promotions") + .all(); + + const students = database.prepare( + "select userId, firstName, lastName, mail, promotionId from students", + ).all(); + + return new Response( + JSON.stringify({ promotions, students }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }, + + async POST(request) { + console.log("API /students/api/insert_students called"); + + try { + const body = await request.json(); + const { data, promoName } = body; + + console.log("Received data:", { promoName, data }); + + if (!promoName || !Array.isArray(data)) { + throw new Error("Invalid request body"); + } + + using connection = connect("students"); + + connection.database.prepare( + "INSERT OR IGNORE INTO promotions (name) VALUES (?)", + ).run(promoName); + + const promoIdRow: { id: number } = connection.database + .prepare("SELECT id FROM promotions WHERE name = ?") + .get(promoName)!; + const promoId = promoIdRow.id; + + console.log(`Promotion ID for "${promoName}":`, promoId); + + const insertQuery = connection.database.prepare( + `INSERT INTO students + (userId, firstName, lastName, mail, promotionId) + VALUES (?, ?, ?, ?, ?)`, + ); + + for (const student of data) { + console.log("Inserting student:", student); + insertQuery.run( + student.Identifiant, + student.Nom, + student["Prénom"], + student.Mail, + promoId, + ); + } + + console.log("All data inserted successfully"); + return new Response("Data inserted successfully", { status: 201 }); + } catch (error) { + console.error("Error inserting data:", error); + return new Response("Failed to insert data", { status: 500 }); + } + }, +}; diff --git a/routes/(apps)/students/api/types.d.ts b/routes/(apps)/students/api/types.d.ts new file mode 100644 index 0000000..95beeb7 --- /dev/null +++ b/routes/(apps)/students/api/types.d.ts @@ -0,0 +1,13 @@ +interface Student { + userId: string; + firstName: string; + lastName: string; + mail: string; + promotionId: number; +} + +interface Promotion { + id: number; + endyear: number; + current: number; +} diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index e506a9b..c46c2a5 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -4,8 +4,7 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; -//import EditStudents from "../(_islands)/EditStudents.tsx"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index f1b9ef8..d64608a 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -4,8 +4,7 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; -//import EditStudents from "../(_islands)/EditStudents.tsx"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await async function Students(_request: Request, _context: FreshContext) { diff --git a/routes/(apps)/students/partials/index.tsx b/routes/(apps)/students/partials/index.tsx index 2971e0e..a54dff7 100644 --- a/routes/(apps)/students/partials/index.tsx +++ b/routes/(apps)/students/partials/index.tsx @@ -3,7 +3,7 @@ import { makePartials, } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; -import { State } from "$root/routes/_middleware.ts"; +import { State } from "$root/defaults/interfaces.ts"; // deno-lint-ignore require-await export async function Index(_request: Request, context: FreshContext) { diff --git a/routes/(apps)/students/partials/overview.tsx b/routes/(apps)/students/partials/overview.tsx deleted file mode 100644 index ceac77c..0000000 --- a/routes/(apps)/students/partials/overview.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Partial } from "$fresh/runtime.ts"; -import { RouteConfig } from "$fresh/server.ts"; - -type ModulesProps = Record; - -export const config: RouteConfig = { - skipAppWrapper: true, - skipInheritedLayouts: true, -}; - -export default function Modules(_props: ModulesProps) { - return ( - - students - - ); -} From 4c54283bfd68fca8f7a8b55bfbefeaa1cb9b6f7e Mon Sep 17 00:00:00 2001 From: Kevin FEDYNA Date: Tue, 28 Jan 2025 10:03:20 +0100 Subject: [PATCH 3/3] Finalized students app --- deno.json | 3 +- fresh.gen.ts | 4 +- .../students/(_components)/Promotion.tsx | 30 ++++ .../students/(_components)/SelfPortrait.tsx | 31 ++++ .../(apps)/students/(_components)/Student.tsx | 13 ++ .../students/(_islands)/ConsultStudents.tsx | 80 +++------- .../students/(_islands)/UploadStudents.tsx | 120 +++++++++----- routes/(apps)/students/api/students.ts | 150 +++++++++--------- .../students/partials/(admin)/consult.tsx | 4 +- .../students/partials/(admin)/upload.tsx | 4 +- routes/(apps)/students/partials/index.tsx | 9 +- routes/(apps)/students/{api => }/types.d.ts | 0 routes/_app.tsx | 1 + static/styles/app.css | 9 +- static/styles/students.css | 15 ++ 15 files changed, 289 insertions(+), 184 deletions(-) create mode 100644 routes/(apps)/students/(_components)/Promotion.tsx create mode 100644 routes/(apps)/students/(_components)/SelfPortrait.tsx create mode 100644 routes/(apps)/students/(_components)/Student.tsx rename routes/(apps)/students/{api => }/types.d.ts (100%) create mode 100644 static/styles/students.css diff --git a/deno.json b/deno.json index 45c5870..c7f729b 100644 --- a/deno.json +++ b/deno.json @@ -35,7 +35,8 @@ "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "$std/": "https://deno.land/std@0.216.0/", - "$root/": "./" + "$root/": "./", + "$apps/": "./routes/(apps)/" }, "compilerOptions": { "jsx": "react-jsx", diff --git a/fresh.gen.ts b/fresh.gen.ts index 3061dcd..eeb5302 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -14,11 +14,11 @@ import * as $_apps_notes_partials_admin_courses from "./routes/(apps)/notes/part import * as $_apps_notes_partials_index from "./routes/(apps)/notes/partials/index.tsx"; import * as $_apps_notes_partials_notes from "./routes/(apps)/notes/partials/notes.tsx"; import * as $_apps_students_api_students from "./routes/(apps)/students/api/students.ts"; -import * as $_apps_students_api_types_d from "./routes/(apps)/students/api/types.d.ts"; import * as $_apps_students_index from "./routes/(apps)/students/index.tsx"; import * as $_apps_students_partials_admin_consult from "./routes/(apps)/students/partials/(admin)/consult.tsx"; import * as $_apps_students_partials_admin_upload from "./routes/(apps)/students/partials/(admin)/upload.tsx"; import * as $_apps_students_partials_index from "./routes/(apps)/students/partials/index.tsx"; +import * as $_apps_students_types_d from "./routes/(apps)/students/types.d.ts"; import * as $_404 from "./routes/_404.tsx"; import * as $_app from "./routes/_app.tsx"; import * as $_middleware from "./routes/_middleware.ts"; @@ -56,7 +56,6 @@ const manifest = { "./routes/(apps)/notes/partials/index.tsx": $_apps_notes_partials_index, "./routes/(apps)/notes/partials/notes.tsx": $_apps_notes_partials_notes, "./routes/(apps)/students/api/students.ts": $_apps_students_api_students, - "./routes/(apps)/students/api/types.d.ts": $_apps_students_api_types_d, "./routes/(apps)/students/index.tsx": $_apps_students_index, "./routes/(apps)/students/partials/(admin)/consult.tsx": $_apps_students_partials_admin_consult, @@ -64,6 +63,7 @@ const manifest = { $_apps_students_partials_admin_upload, "./routes/(apps)/students/partials/index.tsx": $_apps_students_partials_index, + "./routes/(apps)/students/types.d.ts": $_apps_students_types_d, "./routes/_404.tsx": $_404, "./routes/_app.tsx": $_app, "./routes/_middleware.ts": $_middleware, diff --git a/routes/(apps)/students/(_components)/Promotion.tsx b/routes/(apps)/students/(_components)/Promotion.tsx new file mode 100644 index 0000000..985b637 --- /dev/null +++ b/routes/(apps)/students/(_components)/Promotion.tsx @@ -0,0 +1,30 @@ +import Student from "$root/routes/(apps)/students/(_components)/Student.tsx"; + +type PromotionProps = { students: Student[]; promo: Promotion }; + +export default function Promotion(props: PromotionProps) { + if (!props.promo) { + return

Unable to find user in database.

; + } + + return ( +
+

Promotion {props.promo.endyear}

+ + + + + + + + + + + {props.students + .filter((student) => student.promotionId === props.promo.id) + .map((student) => )} + +
IDFirst NameLast NameEmail
+
+ ); +} diff --git a/routes/(apps)/students/(_components)/SelfPortrait.tsx b/routes/(apps)/students/(_components)/SelfPortrait.tsx new file mode 100644 index 0000000..a505298 --- /dev/null +++ b/routes/(apps)/students/(_components)/SelfPortrait.tsx @@ -0,0 +1,31 @@ +import { CasContent } from "$root/defaults/interfaces.ts"; + +type SelfPortraitProps = { self: CasContent }; + +const regex = + /^(?\d{4})(?\d{2})(?\d{2})(?\d{2})(?\d{2})(?\d{2})Z$/; + +export default function SelfPortrait(props: SelfPortraitProps) { + const { year, month, date, hours, minutes, seconds } = props.self + .amuDateValidation.match(regex)!.groups!; + + const validationIsoDate = + `${year}-${month}-${date}T${hours}:${minutes}:${seconds}Z`; + + const validationDate = new Date(validationIsoDate); + + return ( +
+
Identity
+
{props.self.supannCivilite} {props.self.displayName}
+
Student number
+
{props.self.uid}
+
amU mail
+
{props.self.mail}
+
First amU registration
+
{validationDate.toLocaleString()}
+
amU class code
+
{props.self.supannEtuEtape}
+
+ ); +} diff --git a/routes/(apps)/students/(_components)/Student.tsx b/routes/(apps)/students/(_components)/Student.tsx new file mode 100644 index 0000000..7da0e84 --- /dev/null +++ b/routes/(apps)/students/(_components)/Student.tsx @@ -0,0 +1,13 @@ +type StudentProps = { student: Student; promo?: number }; + +export default function Student(props: StudentProps) { + return ( + + {props.student.userId} + {props.student.firstName} + {props.student.lastName} + {props.student.mail} + {props.promo && {props.promo}} + + ); +} diff --git a/routes/(apps)/students/(_islands)/ConsultStudents.tsx b/routes/(apps)/students/(_islands)/ConsultStudents.tsx index 835754f..f67036b 100644 --- a/routes/(apps)/students/(_islands)/ConsultStudents.tsx +++ b/routes/(apps)/students/(_islands)/ConsultStudents.tsx @@ -1,75 +1,45 @@ import { useEffect, useState } from "preact/hooks"; +import Promotion from "$root/routes/(apps)/students/(_components)/Promotion.tsx"; -interface Promotion { - id: number; - name: string; -} +type SingleUserResponse = { promo: Promotion; student: Student }; +type ManyUsersResponse = { promos: Promotion[]; students: Student[] }; -interface Student { - userId: string; - firstName: string; - lastName: string; - mail: string; - promotionId: number; -} +type APIResponse = SingleUserResponse | ManyUsersResponse; export default function ConsultStudents() { - const [data, setData] = useState< - { promotions: Promotion[]; students: Student[] } | null - >(null); + const [data, setData] = useState(null); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { - try { - const response = await fetch("/students/api/students"); - if (!response.ok) { - throw new Error(`Error fetching data: ${response.statusText}`); - } - - const result = await response.json(); - console.log("Fetched data:", result); - setData(result); - } catch (err) { - console.error("Error fetching data:", err); + const response = await fetch("/students/api/students"); + if (!response.ok) { setError("Failed to load data. Please try again later."); } + + const result: APIResponse = await response.json(); + setData(result); }; fetchData(); }, []); return ( -
-

Consult Students

+ <> {error &&

{error}

} - {data?.promotions.map((promo) => ( -
-

Promotion: {promo.name}

- - - - - - - - - - - {data.students - .filter((student) => student.promotionId === promo.id) - .map((student) => ( - - - - - - - ))} - -
IDFirst NameLast NameEmail
{student.userId}{student.firstName}{student.lastName}{student.mail}
-
- ))} -
+ {data && ((Object.hasOwn(data, "student")) + ? ( + + ) + : (data as ManyUsersResponse).promos.map((promo) => ( + + )))} + ); } diff --git a/routes/(apps)/students/(_islands)/UploadStudents.tsx b/routes/(apps)/students/(_islands)/UploadStudents.tsx index 6ff1d5a..1ae11d7 100644 --- a/routes/(apps)/students/(_islands)/UploadStudents.tsx +++ b/routes/(apps)/students/(_islands)/UploadStudents.tsx @@ -1,75 +1,111 @@ // @deno-types="https://cdn.sheetjs.com/xlsx-0.20.3/package/types/index.d.ts" import * as XLSX from "https://cdn.sheetjs.com/xlsx-0.20.3/package/xlsx.mjs"; -import { useSignal } from "@preact/signals"; +import { Signal, useSignal } from "@preact/signals"; -export default function UploadStudents() { - const statusMessage = useSignal(""); - const fileData = useSignal(null); - - const handleFileChange = (event: Event) => { +/** + * Create a new handler for file change that displays + * messages in statusMessage and gets file data in fileData. + * @param statusMessage The status message signal. + * @param fileData The file data signal. + * @returns The file change handler. + */ +function getFileChangeHandler( + statusMessage: Signal, + fileData: Signal, +): (event: Event) => void { + /** + * Handle file change. + * @param event The file change event. + */ + return (event: Event) => { const input = event.target as HTMLInputElement; if (input.files && input.files.length > 0) { fileData.value = input.files[0]; - statusMessage.value = "File selected: " + input.files[0].name; + statusMessage.value = `File selected: ${input.files[0].name}`; } else { fileData.value = null; statusMessage.value = "No file selected"; } }; +} - const confirmUpload = () => { +/** + * Create a new handler that sends data file to server. + * @param statusMessage The status message signal. + * @param fileData The file data signal. + * @returns The file confirmation handler. + */ +function getUploadConfirmationFunction( + statusMessage: Signal, + fileData: Signal, +): () => void { + /** + * Add students to database. + * @returns Confirm upload of students. + */ + return () => { if (!fileData.value) { statusMessage.value = "Please select a file before confirming upload."; return; } - try { - const reader = new FileReader(); + const reader = new FileReader(); - reader.onload = async (e) => { - const arrayBuffer = e.target?.result as ArrayBuffer; - const workbook = XLSX.read(arrayBuffer, { type: "array" }); + /** + * Send all data to the server. + * @param event The finished progress event. + */ + reader.onload = async (event: ProgressEvent) => { + const arrayBuffer = event.target!.result as ArrayBuffer; + const workbook = XLSX.read(arrayBuffer, { type: "array" }); + let allOK = true; - for (const sheetName of workbook.SheetNames) { - const sheet = workbook.Sheets[sheetName]; - const data = XLSX.utils.sheet_to_json(sheet, { - header: ["Identifiant", "Nom", "Prénom", "Mail"], - range: 1, // Ignorer les en-têtes - }); + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + const data = XLSX.utils.sheet_to_json(sheet, { + header: ["userId", "lastName", "firstName", "mail"], + range: 1, + }); - console.log(`Data from sheet ${sheetName}:`, data); + const response = await fetch("/students/api/students", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ promoName: sheetName, data }), + }); - const response = await fetch("/students/api/students", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ promoName: sheetName, data }), - }); - - if (!response.ok) { - throw new Error(`Failed to insert data for promotion ${sheetName}`); - } + if (!response.ok) { + allOK = false; } + } - statusMessage.value = "Data uploaded and inserted successfully!"; - }; + statusMessage.value = allOK + ? "Failed to insert all data." + : "Data uploaded and inserted successfully!"; + }; - reader.onerror = () => { - statusMessage.value = "Error reading the file."; - }; + /** + * Display error message if any. + */ + reader.onerror = () => { + statusMessage.value = "Error reading the file."; + }; - reader.readAsArrayBuffer(fileData.value); - } catch (error) { - console.error("Error uploading file:", error); - statusMessage.value = "An unexpected error occurred during upload."; - } + reader.readAsArrayBuffer(fileData.value); }; +} + +export default function UploadStudents() { + const statusMessage = useSignal(""); + const fileData = useSignal(null); + + const handleFileChange = getFileChangeHandler(statusMessage, fileData); + const confirmUpload = getUploadConfirmationFunction(statusMessage, fileData); return ( -
-

Upload Students

+ <>

{statusMessage.value}

-
+ ); } diff --git a/routes/(apps)/students/api/students.ts b/routes/(apps)/students/api/students.ts index eb1dd2b..157299f 100644 --- a/routes/(apps)/students/api/students.ts +++ b/routes/(apps)/students/api/students.ts @@ -32,31 +32,50 @@ function getItself( /** * Gets itself from the database. - * @param database The database connection + * @param database The database connexion * @param userId The user ID. * @returns Itself from the database. */ function getAll( database: Database, - userId: string, -): { student: Student | null; promo: Promotion | null } { - const studentQuery = "select * from students where userId = ?"; - const student: Student | undefined = database.prepare(studentQuery).get( - userId, - ); +): { students: Student[]; promos: Promotion[] } { + const studentsQuery = ` + select userId, firstName, lastName, mail, promotionId + from students inner join promotions + on students.promotionId = promotions.id + where promotions.current < 6`; + const students: Student[] = database.prepare(studentsQuery).all(); - if (!student) { - return { student: null, promo: null }; - } + const promosQuery = "select * from promotions where promotions.current < 6"; + const promos: Promotion[] | undefined = database.prepare(promosQuery).all(); - const promoQuery = "select * from promotions where id = ?"; - const promo: Promotion | undefined = database.prepare(promoQuery).get( - student.promotionId, - ); - - return { student, promo: promo ?? null }; + return { students, promos }; } +/** + * Add users to the database. + * @param database The database connexion + * @param students The students to add + * @param promoId The promotion id. + */ +function addStudents(database: Database, students: Student[], promoId: string) { + const query = ` + INSERT INTO students + (userId, firstName, lastName, mail, promotionId) + VALUES (?, ?, ?, ?, ?)`; + + const statement = database.prepare(query); + + for (const student of students) { + statement.run( + student.userId, + student.firstName, + student.lastName, + student.mail, + promoId, + ); + } +} export const handler: Handlers = { /** @@ -75,9 +94,7 @@ export const handler: Handlers = { if (context.state.session.eduPersonPrimaryAffiliation == "student") { return new Response( - JSON.stringify({ - student: getItself(database, context.state.session.uid), - }), + JSON.stringify(getItself(database, context.state.session.uid)), { headers: { "content-type": "application/json", @@ -86,70 +103,49 @@ export const handler: Handlers = { ); } - const promotions = database.prepare("select id, name from promotions") - .all(); - - const students = database.prepare( - "select userId, firstName, lastName, mail, promotionId from students", - ).all(); - return new Response( - JSON.stringify({ promotions, students }), + JSON.stringify(getAll(database)), { - status: 200, - headers: { "Content-Type": "application/json" }, + headers: { + "content-type": "application/json", + }, }, ); }, + /** + * Add students in the database. + * @param request The HTTP request. + * @param _context The Fresh context. + * @returns HTTP 201 on successful insert. + */ + async POST( + request: Request, + _context: FreshContext, + ): Promise { + const { students, promo }: { students: Student[]; promo: string } = + await request.json(); - async POST(request) { - console.log("API /students/api/insert_students called"); - - try { - const body = await request.json(); - const { data, promoName } = body; - - console.log("Received data:", { promoName, data }); - - if (!promoName || !Array.isArray(data)) { - throw new Error("Invalid request body"); - } - - using connection = connect("students"); - - connection.database.prepare( - "INSERT OR IGNORE INTO promotions (name) VALUES (?)", - ).run(promoName); - - const promoIdRow: { id: number } = connection.database - .prepare("SELECT id FROM promotions WHERE name = ?") - .get(promoName)!; - const promoId = promoIdRow.id; - - console.log(`Promotion ID for "${promoName}":`, promoId); - - const insertQuery = connection.database.prepare( - `INSERT INTO students - (userId, firstName, lastName, mail, promotionId) - VALUES (?, ?, ?, ?, ?)`, - ); - - for (const student of data) { - console.log("Inserting student:", student); - insertQuery.run( - student.Identifiant, - student.Nom, - student["Prénom"], - student.Mail, - promoId, - ); - } - - console.log("All data inserted successfully"); - return new Response("Data inserted successfully", { status: 201 }); - } catch (error) { - console.error("Error inserting data:", error); - return new Response("Failed to insert data", { status: 500 }); + if (!promo || !promo.match(/^\d{4}-\dA$/) || !Array.isArray(students)) { + return new Response(null, { status: 400 }); } + + using connection = connect("students"); + const database = connection.database; + + const { endyear, current } = promo.match( + /^(?\d{4})-(?\d)A$/, + )?.groups!; + + database.prepare( + "insert or ignore into promotions (endyear, current) values (?, ?)", + ).run(endyear, current); + + const { id: promoId }: { id: string } = database + .prepare("select id from promotions where endyear = ? and current = ?") + .get(endyear, current)!; + + addStudents(database, students, promoId); + + return new Response(null, { status: 201 }); }, }; diff --git a/routes/(apps)/students/partials/(admin)/consult.tsx b/routes/(apps)/students/partials/(admin)/consult.tsx index c46c2a5..b685c5c 100644 --- a/routes/(apps)/students/partials/(admin)/consult.tsx +++ b/routes/(apps)/students/partials/(admin)/consult.tsx @@ -1,4 +1,4 @@ -import ConsultStudents from "$root/routes/(apps)/students/(_islands)/ConsultStudents.tsx"; +import ConsultStudents from "../../(_islands)/ConsultStudents.tsx"; import { getPartialsConfig, makePartials, @@ -10,7 +10,7 @@ import { State } from "$root/defaults/interfaces.ts"; async function Students(_request: Request, _context: FreshContext) { return ( <> -

Manage Promotions

+

Consult students

); diff --git a/routes/(apps)/students/partials/(admin)/upload.tsx b/routes/(apps)/students/partials/(admin)/upload.tsx index d64608a..2f36f6d 100644 --- a/routes/(apps)/students/partials/(admin)/upload.tsx +++ b/routes/(apps)/students/partials/(admin)/upload.tsx @@ -1,4 +1,4 @@ -import UploadStudents from "$root/routes/(apps)/students/(_islands)/UploadStudents.tsx"; +import UploadStudents from "../../(_islands)/UploadStudents.tsx"; import { getPartialsConfig, makePartials, @@ -10,7 +10,7 @@ import { State } from "$root/defaults/interfaces.ts"; async function Students(_request: Request, _context: FreshContext) { return ( <> -

Manage Promotions

+

Upload Students

); diff --git a/routes/(apps)/students/partials/index.tsx b/routes/(apps)/students/partials/index.tsx index a54dff7..78931b5 100644 --- a/routes/(apps)/students/partials/index.tsx +++ b/routes/(apps)/students/partials/index.tsx @@ -4,10 +4,17 @@ import { } from "$root/defaults/makePartials.tsx"; import { FreshContext } from "$fresh/server.ts"; import { State } from "$root/defaults/interfaces.ts"; +import SelfPortrait from "$root/routes/(apps)/students/(_components)/SelfPortrait.tsx"; // deno-lint-ignore require-await export async function Index(_request: Request, context: FreshContext) { - return

Welcome to {context.state.session?.displayName}.

; + return ( + <> +

Welcome {context.state.session?.givenName}!

+

Your amU identity

+ + + ); } export const config = getPartialsConfig(); diff --git a/routes/(apps)/students/api/types.d.ts b/routes/(apps)/students/types.d.ts similarity index 100% rename from routes/(apps)/students/api/types.d.ts rename to routes/(apps)/students/types.d.ts diff --git a/routes/_app.tsx b/routes/_app.tsx index 8b253b8..60d03a9 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -27,6 +27,7 @@ export default async function App( +
diff --git a/static/styles/app.css b/static/styles/app.css index 8ddee03..8395076 100644 --- a/static/styles/app.css +++ b/static/styles/app.css @@ -3,7 +3,7 @@ padding: 1em 0; display: grid; grid-template-columns: auto 1fr; - gap: 1em; + gap: 4em; } #app > #app-body { @@ -16,7 +16,7 @@ } #app > nav > a { - padding: 0.25em 0.5em; + padding: 0.5em 4em 0.5em 1em; color: light-dark(var(--light-foreground), var(--dark-foreground)); } @@ -57,5 +57,10 @@ #app { grid-template-rows: auto 1fr; grid-template-columns: none; + gap: 1em; + } + + #app > nav > a { + padding: 0.5em 1em; } } diff --git a/static/styles/students.css b/static/styles/students.css new file mode 100644 index 0000000..d7398bd --- /dev/null +++ b/static/styles/students.css @@ -0,0 +1,15 @@ +#self-portrait { + display: grid; + gap: 1em; + grid-template-columns: auto 1fr; +} + +#self-portrait > div:nth-child(2n+1) { + font-weight: var(--font-weight-bold); +} + +@media screen and (max-width: 1024px) { + #self-portrait { + grid-template-columns: 1fr; + } +}