Météo Exam v0.1.0 — calendrier des évaluations en bulletin météo
- API Express + SQLite : classes, évaluations, SSE, auth Bearer - Front Vite + TS + Tailwind : frise 7 jours, Exam Cards, modales - Capacitor Android : APK debug, CapacitorHttp natif - Seed : classe Terminale S2 (code TERMS2)
This commit is contained in:
commit
6d154ed5fb
92 changed files with 9574 additions and 0 deletions
28
server/package.json
Normal file
28
server/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@meteo-exam/server",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@meteo-exam/shared": "*",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
138
server/src/auth.ts
Normal file
138
server/src/auth.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { randomBytes } from 'node:crypto'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import type { ClassInfo, Member, Role } from '@meteo-exam/shared'
|
||||
import { CLASS_CODE_LENGTH } from '@meteo-exam/shared'
|
||||
import { db, nowIso } from './db.js'
|
||||
|
||||
interface MemberRow {
|
||||
id: number
|
||||
class_id: number
|
||||
nickname: string
|
||||
role: Role
|
||||
class_name: string
|
||||
class_level: string | null
|
||||
}
|
||||
|
||||
export const newToken = (): string => randomBytes(32).toString('base64url')
|
||||
|
||||
/** Code d'invitation lisible au tableau : pas de 0/O ni de 1/I. */
|
||||
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
export function newClassCode(): string {
|
||||
const codes = db.prepare<[], { code: string }>('SELECT code FROM classes').all()
|
||||
const taken = new Set(codes.map((r) => r.code))
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
let code = ''
|
||||
for (let i = 0; i < CLASS_CODE_LENGTH; i++) {
|
||||
code += CODE_ALPHABET[randomBytes(1)[0]! % CODE_ALPHABET.length]
|
||||
}
|
||||
if (!taken.has(code)) return code
|
||||
}
|
||||
throw new Error('Impossible de générer un code de classe unique')
|
||||
}
|
||||
|
||||
export interface NewSession {
|
||||
token: string
|
||||
member: Member
|
||||
klass: ClassInfo
|
||||
}
|
||||
|
||||
export function createClass(name: string, level: string | null, nickname: string): NewSession & { code: string } {
|
||||
const code = newClassCode()
|
||||
const now = nowIso()
|
||||
const token = newToken()
|
||||
const create = db.transaction((): NewSession => {
|
||||
const classId = db
|
||||
.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(name, level, code, now).lastInsertRowid as number
|
||||
const memberId = db
|
||||
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(classId, nickname, 'admin', token, now).lastInsertRowid as number
|
||||
return {
|
||||
token,
|
||||
member: { id: memberId, classId, nickname, role: 'admin' },
|
||||
klass: { id: classId, name, level },
|
||||
}
|
||||
})
|
||||
return { code, ...create() }
|
||||
}
|
||||
|
||||
export function joinClass(code: string, nickname: string): NewSession | null {
|
||||
const row = db
|
||||
.prepare<[string], { id: number; name: string; level: string | null }>(
|
||||
'SELECT id, name, level FROM classes WHERE code = ?',
|
||||
)
|
||||
.get(code)
|
||||
if (!row) return null
|
||||
const token = newToken()
|
||||
const memberId = db
|
||||
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(row.id, nickname, 'student', token, nowIso()).lastInsertRowid as number
|
||||
return {
|
||||
token,
|
||||
member: { id: memberId, classId: row.id, nickname, role: 'student' },
|
||||
klass: { id: row.id, name: row.name, level: row.level },
|
||||
}
|
||||
}
|
||||
|
||||
export function findSession(token: string): { member: Member; klass: ClassInfo } | null {
|
||||
const row = db
|
||||
.prepare<[string], MemberRow>(
|
||||
`SELECT m.id, m.class_id, m.nickname, m.role,
|
||||
c.name AS class_name, c.level AS class_level
|
||||
FROM members m
|
||||
JOIN classes c ON c.id = m.class_id
|
||||
WHERE m.token = ?`,
|
||||
)
|
||||
.get(token)
|
||||
if (!row) return null
|
||||
return {
|
||||
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role },
|
||||
klass: { id: row.class_id, name: row.class_name, level: row.class_level },
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
session?: { member: Member; klass: ClassInfo }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jeton Bearer en en-tête `Authorization`. Sur Android le WebView Capacitor
|
||||
* est en cross-origin : un cookie httpOnly ne serait pas envoyé par le
|
||||
* navigateur embarqué, donc le client stocke le jeton et le renvoie ici.
|
||||
*
|
||||
* `?token=` est également accepté, car `EventSource` ne sait pas poser
|
||||
* d'en-têtes — c'est le seul moyen d'authentifier le flux SSE.
|
||||
*/
|
||||
function readToken(req: Request): string | null {
|
||||
const header = req.headers.authorization
|
||||
if (typeof header === 'string' && header.startsWith('Bearer ')) {
|
||||
return header.slice(7).trim() || null
|
||||
}
|
||||
const query = req.query['token']
|
||||
return typeof query === 'string' && query.length > 0 ? query : null
|
||||
}
|
||||
|
||||
export function requireSession(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = readToken(req)
|
||||
const session = token ? findSession(token) : null
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Session inconnue. Rejoignez une classe pour continuer.' })
|
||||
return
|
||||
}
|
||||
req.session = session
|
||||
next()
|
||||
}
|
||||
|
||||
/** Réservé à l'admin de la classe (celui qui l'a créée). */
|
||||
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session?.member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Seul l’admin de la classe peut modifier le calendrier.' })
|
||||
return
|
||||
}
|
||||
next()
|
||||
}
|
||||
58
server/src/db.ts
Normal file
58
server/src/db.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import Database from 'better-sqlite3'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db')
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true })
|
||||
|
||||
export const db = new Database(DB_PATH)
|
||||
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||
nickname TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin','student')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_members_token ON members(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_members_class ON members(class_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
|
||||
subject TEXT NOT NULL,
|
||||
teacher TEXT,
|
||||
room TEXT,
|
||||
chapter TEXT,
|
||||
starts_at TEXT NOT NULL,
|
||||
duration_min INTEGER NOT NULL,
|
||||
weight REAL NOT NULL DEFAULT 1,
|
||||
kind TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_by INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_class_start ON exams(class_id, starts_at);
|
||||
`)
|
||||
|
||||
export const nowIso = (): string => new Date().toISOString()
|
||||
|
||||
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */
|
||||
export const prepareChanges = db.prepare.bind(db)
|
||||
30
server/src/events.ts
Normal file
30
server/src/events.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { Response } from 'express'
|
||||
import type { StreamEvent } from '@meteo-exam/shared'
|
||||
|
||||
/** clients SSE ouverts, groupés par classId. */
|
||||
const clients = new Map<number, Set<Response>>()
|
||||
|
||||
export function addClient(classId: number, res: Response): () => void {
|
||||
let set = clients.get(classId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
clients.set(classId, set)
|
||||
}
|
||||
set.add(res)
|
||||
|
||||
return () => {
|
||||
set.delete(res)
|
||||
if (set.size === 0) clients.delete(classId)
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcast(classId: number, event: StreamEvent): void {
|
||||
const set = clients.get(classId)
|
||||
if (!set) return
|
||||
const payload = `data: ${JSON.stringify(event)}\n\n`
|
||||
for (const res of set) {
|
||||
res.write(payload)
|
||||
}
|
||||
}
|
||||
|
||||
export const clientCount = (classId: number): number => clients.get(classId)?.size ?? 0
|
||||
68
server/src/index.ts
Normal file
68
server/src/index.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import express from 'express'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { classesRouter } from './routes/classes.js'
|
||||
import { examsRouter } from './routes/exams.js'
|
||||
import { streamRouter } from './routes/stream.js'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const PORT = Number(process.env['PORT'] ?? 8787)
|
||||
|
||||
/** Origines autorisées. L'app Android (WebView) est en cross-origin. */
|
||||
const ALLOW_ORIGINS = (process.env['METEO_ORIGINS'] ?? '*')
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.use(express.json({ limit: '64kb' }))
|
||||
|
||||
// L'API ne renvoie aucun cookie : le client porte un jeton Bearer.
|
||||
// `Authorization` doit donc être autorisé explicitement.
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin
|
||||
res.set('Access-Control-Allow-Origin', ALLOW_ORIGINS.includes('*') ? (origin ?? '*') : (origin && ALLOW_ORIGINS.includes(origin) ? origin : ALLOW_ORIGINS[0] ?? '*'))
|
||||
res.set('Vary', 'Origin')
|
||||
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||
res.set('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
res.set('Access-Control-Expose-Headers', 'Content-Type')
|
||||
res.set('Access-Control-Max-Age', '600')
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(204).end()
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.use('/api/classes', classesRouter)
|
||||
app.use('/api/exams', examsRouter)
|
||||
app.use('/api/stream', streamRouter)
|
||||
|
||||
app.use('/api', (_req, res) => {
|
||||
res.status(404).json({ error: 'Route inconnue' })
|
||||
})
|
||||
|
||||
// En production, le front compilé est servi par le même serveur. Utile pour
|
||||
// un déploiement web ; sur Android, l'app Capacitor charge ses assets locale.
|
||||
const webDist = resolve(here, '../../web/dist')
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist))
|
||||
app.get(/.*/, (_req, res) => {
|
||||
res.sendFile(resolve(webDist, 'index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Météo Exam — API sur http://localhost:${PORT}`)
|
||||
if (ALLOW_ORIGINS.includes('*')) {
|
||||
console.log('CORS ouvert à toutes les origines. Restreignez avec METEO_ORIGINS en production.')
|
||||
}
|
||||
if (!existsSync(webDist)) {
|
||||
console.log('Front non compilé : lancez `npm run dev` (Vite sur 5173) ou `npm run build`.')
|
||||
}
|
||||
})
|
||||
49
server/src/routes/classes.ts
Normal file
49
server/src/routes/classes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { createClass, joinClass, requireSession } from '../auth.js'
|
||||
|
||||
export const classesRouter = Router()
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().trim().min(2, 'Nom de classe trop court').max(60),
|
||||
level: z.string().trim().max(40).nullish(),
|
||||
nickname: z.string().trim().min(1, 'Choisissez un pseudo').max(30),
|
||||
})
|
||||
|
||||
const joinSchema = z.object({
|
||||
code: z.string().trim().min(1, 'Entrez le code de classe').max(12),
|
||||
nickname: z.string().trim().min(1, 'Choisissez un pseudo').max(30),
|
||||
})
|
||||
|
||||
/** Crée une classe, ouvre une session admin et renvoie le jeton + le code d'invitation. */
|
||||
classesRouter.post('/', (req, res) => {
|
||||
const parsed = createSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const { name, level, nickname } = parsed.data
|
||||
const { code, token, member, klass } = createClass(name, level ?? null, nickname)
|
||||
res.status(201).json({ token, code, member, klass })
|
||||
})
|
||||
|
||||
/** Rejoint une classe existante via son code. */
|
||||
classesRouter.post('/join', (req, res) => {
|
||||
const parsed = joinSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const { code, nickname } = parsed.data
|
||||
const session = joinClass(code.toUpperCase(), nickname)
|
||||
if (!session) {
|
||||
res.status(404).json({ error: 'Code de classe inconnu. Vérifiez les caractères.' })
|
||||
return
|
||||
}
|
||||
res.status(201).json(session)
|
||||
})
|
||||
|
||||
/** Session courante : sert à valider un jeton au démarrage de l'app. */
|
||||
classesRouter.get('/me', requireSession, (req, res) => {
|
||||
res.json(req.session)
|
||||
})
|
||||
186
server/src/routes/exams.ts
Normal file
186
server/src/routes/exams.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { Router } from 'express'
|
||||
import { z } from 'zod'
|
||||
import type { Exam, WeatherKind } from '@meteo-exam/shared'
|
||||
import { isWeatherKind, kindFromWeight } from '@meteo-exam/shared'
|
||||
import { db, nowIso } from '../db.js'
|
||||
import { requireSession, requireAdmin } from '../auth.js'
|
||||
import { broadcast } from '../events.js'
|
||||
|
||||
export const examsRouter = Router()
|
||||
|
||||
interface ExamRow {
|
||||
id: number
|
||||
class_id: number
|
||||
subject: string
|
||||
teacher: string | null
|
||||
room: string | null
|
||||
chapter: string | null
|
||||
starts_at: string
|
||||
duration_min: number
|
||||
weight: number
|
||||
kind: WeatherKind
|
||||
note: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
const toExam = (row: ExamRow): Exam => ({
|
||||
id: row.id,
|
||||
classId: row.class_id,
|
||||
subject: row.subject,
|
||||
teacher: row.teacher,
|
||||
room: row.room,
|
||||
chapter: row.chapter,
|
||||
startsAt: row.starts_at,
|
||||
durationMin: row.duration_min,
|
||||
weight: row.weight,
|
||||
kind: row.kind,
|
||||
note: row.note,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
})
|
||||
|
||||
const bodySchema = z.object({
|
||||
subject: z.string().trim().min(1, 'La matière est obligatoire').max(60),
|
||||
teacher: z.string().trim().max(60).nullish(),
|
||||
room: z.string().trim().max(30).nullish(),
|
||||
chapter: z.string().trim().max(160).nullish(),
|
||||
startsAt: z.string().datetime({ message: 'Date invalide' }),
|
||||
durationMin: z.number().int().min(5, 'Durée minimum : 5 min').max(480, 'Durée maximum : 8 h'),
|
||||
weight: z.number().min(0).max(20),
|
||||
kind: z.enum(['soleil', 'brouillard', 'vent', 'orage', 'tempete']).nullish(),
|
||||
note: z.string().trim().max(400).nullish(),
|
||||
})
|
||||
|
||||
const patchSchema = bodySchema.partial()
|
||||
|
||||
/** Liste les évaluations d'une fenêtre temporelle, triées par date croissante. */
|
||||
examsRouter.get('/', requireSession, (req, res) => {
|
||||
const classId = req.session!.member.classId
|
||||
const from = typeof req.query['from'] === 'string' ? req.query['from'] : null
|
||||
const to = typeof req.query['to'] === 'string' ? req.query['to'] : null
|
||||
|
||||
const clauses = ['class_id = ?']
|
||||
const params: (string | number)[] = [classId]
|
||||
if (from) {
|
||||
clauses.push('starts_at >= ?')
|
||||
params.push(from)
|
||||
}
|
||||
if (to) {
|
||||
clauses.push('starts_at < ?')
|
||||
params.push(to)
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.prepare<unknown[], ExamRow>(
|
||||
`SELECT * FROM exams WHERE ${clauses.join(' AND ')} ORDER BY starts_at ASC`,
|
||||
)
|
||||
.all(...params)
|
||||
res.json(rows.map(toExam))
|
||||
})
|
||||
|
||||
examsRouter.post('/', requireSession, requireAdmin, (req, res) => {
|
||||
const parsed = bodySchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const data = parsed.data
|
||||
const now = nowIso()
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO exams
|
||||
(class_id, subject, teacher, room, chapter, starts_at, duration_min, weight, kind, note, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
req.session!.member.classId,
|
||||
data.subject,
|
||||
data.teacher ?? null,
|
||||
data.room ?? null,
|
||||
data.chapter ?? null,
|
||||
new Date(data.startsAt).toISOString(),
|
||||
data.durationMin,
|
||||
data.weight,
|
||||
data.kind ?? kindFromWeight(data.weight),
|
||||
data.note ?? null,
|
||||
req.session!.member.id,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
|
||||
const examId = Number(info.lastInsertRowid)
|
||||
const row = db.prepare<[number], ExamRow>('SELECT * FROM exams WHERE id = ?').get(examId)!
|
||||
const exam = toExam(row)
|
||||
broadcast(exam.classId, { type: 'exam.created', exam })
|
||||
res.status(201).json(exam)
|
||||
})
|
||||
|
||||
examsRouter.patch('/:id', requireSession, requireAdmin, (req, res) => {
|
||||
const id = Number(req.params['id'])
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Identifiant invalide' })
|
||||
return
|
||||
}
|
||||
const existing = db
|
||||
.prepare<[number, number], ExamRow>('SELECT * FROM exams WHERE id = ? AND class_id = ?')
|
||||
.get(id, req.session!.member.classId)
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Évaluation introuvable' })
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = patchSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
|
||||
return
|
||||
}
|
||||
const d = parsed.data
|
||||
const nextWeight = d.weight ?? existing.weight
|
||||
const kind = d.kind ?? (d.weight !== undefined ? kindFromWeight(nextWeight) : existing.kind)
|
||||
if (!isWeatherKind(kind)) {
|
||||
res.status(400).json({ error: 'Statut météo inconnu' })
|
||||
return
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE exams
|
||||
SET subject = ?, teacher = ?, room = ?, chapter = ?, starts_at = ?,
|
||||
duration_min = ?, weight = ?, kind = ?, note = ?, updated_at = ?
|
||||
WHERE id = ? AND class_id = ?`,
|
||||
).run(
|
||||
d.subject ?? existing.subject,
|
||||
d.teacher === undefined ? existing.teacher : d.teacher,
|
||||
d.room === undefined ? existing.room : d.room,
|
||||
d.chapter === undefined ? existing.chapter : d.chapter,
|
||||
d.startsAt ? new Date(d.startsAt).toISOString() : existing.starts_at,
|
||||
d.durationMin ?? existing.duration_min,
|
||||
nextWeight,
|
||||
kind,
|
||||
d.note === undefined ? existing.note : d.note,
|
||||
nowIso(),
|
||||
id,
|
||||
req.session!.member.classId,
|
||||
)
|
||||
|
||||
const row = db.prepare<[number], ExamRow>('SELECT * FROM exams WHERE id = ?').get(id)!
|
||||
const exam = toExam(row)
|
||||
broadcast(exam.classId, { type: 'exam.updated', exam })
|
||||
res.json(exam)
|
||||
})
|
||||
|
||||
examsRouter.delete('/:id', requireSession, requireAdmin, (req, res) => {
|
||||
const id = Number(req.params['id'])
|
||||
if (!Number.isInteger(id)) {
|
||||
res.status(400).json({ error: 'Identifiant invalide' })
|
||||
return
|
||||
}
|
||||
const classId = req.session!.member.classId
|
||||
const info = db.prepare<[number, number], { changes: number }>('DELETE FROM exams WHERE id = ? AND class_id = ?').run(id, classId)
|
||||
if (info.changes === 0) {
|
||||
res.status(404).json({ error: 'Évaluation introuvable' })
|
||||
return
|
||||
}
|
||||
broadcast(classId, { type: 'exam.deleted', examId: id })
|
||||
res.status(204).end()
|
||||
})
|
||||
40
server/src/routes/stream.ts
Normal file
40
server/src/routes/stream.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Router } from 'express'
|
||||
import { requireSession } from '../auth.js'
|
||||
import { addClient } from '../events.js'
|
||||
import type { StreamEvent } from '@meteo-exam/shared'
|
||||
|
||||
export const streamRouter = Router()
|
||||
|
||||
const send = (res: import('express').Response, event: StreamEvent): void => {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flux SSE du calendrier. Le cookie de session est lu au handshake, ce qui
|
||||
* évite un jeton en query string (et le partage d'URL cassé par EventSource).
|
||||
*/
|
||||
streamRouter.get('/', requireSession, (req, res) => {
|
||||
const classId = req.session!.member.classId
|
||||
|
||||
res.status(200)
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
res.flushHeaders()
|
||||
|
||||
// Commente d'ouverture : certains proxys n'envoient rien avant le premier event.
|
||||
res.write(': connected\n\n')
|
||||
send(res, { type: 'ping' })
|
||||
|
||||
const remove = addClient(classId, res)
|
||||
const keepAlive = setInterval(() => send(res, { type: 'ping' }), 25_000)
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(keepAlive)
|
||||
remove()
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
130
server/src/seed.ts
Normal file
130
server/src/seed.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Jeu de données de démonstration : la classe Terminale S2 avec deux semaines
|
||||
* d'évaluations. Idempotent — relancer `npm run seed` ne duplique rien.
|
||||
* Usage : npm run seed [-- --reset]
|
||||
*/
|
||||
import { existsSync, rmSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { db, nowIso } from './db.js'
|
||||
import { newToken } from './auth.js'
|
||||
import type { WeatherKind } from '@meteo-exam/shared'
|
||||
|
||||
const DB_PATH = process.env['METEO_DB'] ?? resolve(process.cwd(), 'data/meteo-exam.db')
|
||||
|
||||
if (process.argv.includes('--reset')) {
|
||||
for (const suffix of ['', '-wal', '-shm']) {
|
||||
const file = `${DB_PATH}${suffix}`
|
||||
if (existsSync(file)) rmSync(file)
|
||||
}
|
||||
console.log('Base effacée. Relancez `npm run seed`.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const existing = db.prepare<[], { n: number }>('SELECT COUNT(*) AS n FROM classes').get()!
|
||||
if (existing.n > 0) {
|
||||
console.log('La base contient déjà des classes — seed ignoré. Utilisez --reset pour repartir de zéro.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const CLASS_NAME = 'Terminale S2'
|
||||
const LEVEL = 'Lycée'
|
||||
const ADMIN_NICK = 'Thomas S.'
|
||||
const CLASS_CODE = 'TERMS2'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
const isWeekend = (d: Date): boolean => d.getDay() === 0 || d.getDay() === 6
|
||||
|
||||
const midnight = new Date()
|
||||
midnight.setHours(0, 0, 0, 0)
|
||||
|
||||
/**
|
||||
* `dayOffset` compte les jours **scolaires** : 0 = aujourd'hui (même si c'est
|
||||
* samedi, le seed reste lisible), puis on saute les weekends.
|
||||
*/
|
||||
function schoolDay(offset: number): Date {
|
||||
const date = new Date(midnight)
|
||||
let counted = 0
|
||||
for (let step = 0; step < 90; step++) {
|
||||
if (counted === offset) break
|
||||
date.setTime(date.getTime() + DAY_MS)
|
||||
if (!isWeekend(date)) counted++
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
interface SeedExam {
|
||||
dayOffset: number
|
||||
hour: number
|
||||
subject: string
|
||||
teacher: string | null
|
||||
room: string | null
|
||||
chapter: string | null
|
||||
durationMin: number
|
||||
weight: number
|
||||
kind: WeatherKind
|
||||
note: string | null
|
||||
}
|
||||
|
||||
const exams: SeedExam[] = [
|
||||
{ dayOffset: 0, hour: 8, subject: 'Mathématiques', teacher: 'M. Roussel', room: 'B204', chapter: 'Intégrales, primitives et dérivation', durationMin: 120, weight: 4, kind: 'tempete', note: 'Calculatrice autorisée, calculatrice graphique non.' },
|
||||
{ dayOffset: 0, hour: 14, subject: 'Histoire-Géographie', teacher: 'Mme Ferrand', room: 'A112', chapter: null, durationMin: 55, weight: 1, kind: 'vent', note: 'Interro surprise probable en début de cours.' },
|
||||
{ dayOffset: 1, hour: 9, subject: 'Physique-Chimie', teacher: 'Mme Nguyen', room: 'Labo 3', chapter: 'Mécanique : second principe', durationMin: 60, weight: 2, kind: 'orage', note: 'Paquet de calculatrices fourni.' },
|
||||
{ dayOffset: 1, hour: 15, subject: 'Anglais', teacher: 'M. Whitfield', room: 'C007', chapter: 'Compréhension orale — Unit 4', durationMin: 30, weight: 1, kind: 'vent', note: 'Rumeur d’une dictée surprise.' },
|
||||
{ dayOffset: 2, hour: 8, subject: 'SVT', teacher: 'Mme Perrin', room: 'S215', chapter: 'Génétique des populations', durationMin: 90, weight: 3, kind: 'orage', note: 'TP suivi d’une synthèse écrite.' },
|
||||
{ dayOffset: 3, hour: 10, subject: 'Philosophie', teacher: 'M. Aubert', room: 'A008', chapter: 'La raison et le doute', durationMin: 120, weight: 3, kind: 'orage', note: 'Dissertation, sujet au choix.' },
|
||||
{ dayOffset: 3, hour: 16, subject: 'EPS', teacher: 'Mme Costa', room: 'Gymnase', chapter: null, durationMin: 60, weight: 1, kind: 'soleil', note: 'Basket obligatoire.' },
|
||||
{ dayOffset: 4, hour: 9, subject: 'Mathématiques', teacher: 'M. Roussel', room: 'B204', chapter: 'Probabilités conditionnelles', durationMin: 60, weight: 2, kind: 'orage', note: null },
|
||||
{ dayOffset: 5, hour: 10, subject: 'Français', teacher: 'Mme Dumas', room: 'A101', chapter: 'Guerre froide — contexte', durationMin: 60, weight: 1, kind: 'vent', note: 'Lecture suivie d’une étude de texte.' },
|
||||
{ dayOffset: 6, hour: 8, subject: 'Examen blanc', teacher: null, room: 'Amphi A', chapter: 'Toutes matières', durationMin: 180, weight: 5, kind: 'tempete', note: 'Blanc général, convocation à 8h précises.' },
|
||||
{ dayOffset: 7, hour: 9, subject: 'SVT', teacher: 'Mme Perrin', room: 'S215', chapter: 'Écosystèmes et bilan de matière', durationMin: 45, weight: 1, kind: 'brouillard', note: 'Brouillard : TP évalué, date encore floue.' },
|
||||
{ dayOffset: 8, hour: 14, subject: 'Anglais', teacher: 'M. Whitfield', room: 'C007', chapter: 'Expression écrite — sujet d’invention', durationMin: 60, weight: 2, kind: 'orage', note: null },
|
||||
{ dayOffset: 9, hour: 8, subject: 'Histoire-Géographie', teacher: 'Mme Ferrand', room: 'A112', chapter: 'La décolonisation', durationMin: 60, weight: 2, kind: 'orage', note: null },
|
||||
{ dayOffset: 10, hour: 10, subject: 'Philosophie', teacher: 'M. Aubert', room: 'A008', chapter: 'Le sujet et l’autre', durationMin: 60, weight: 2, kind: 'orage', note: 'Plan imposé.' },
|
||||
]
|
||||
|
||||
const seed = db.transaction(() => {
|
||||
db.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(CLASS_NAME, LEVEL, CLASS_CODE, nowIso())
|
||||
|
||||
const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?')
|
||||
.get(CLASS_CODE)!.id
|
||||
|
||||
const adminToken = newToken()
|
||||
const adminId = db
|
||||
.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(classId, ADMIN_NICK, 'admin', adminToken, nowIso()).lastInsertRowid as number
|
||||
|
||||
// Quelques élèves : ils donneront du volume aux votes du radar plus tard.
|
||||
for (const nick of ['Léa M.', 'Karim B.', 'Chloé D.', 'Inès R.', 'Lucas P.', 'Mehdi A.']) {
|
||||
db.prepare('INSERT INTO members (class_id, nickname, role, token, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(classId, nick, 'student', newToken(), nowIso())
|
||||
}
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO exams
|
||||
(class_id, subject, teacher, room, chapter, starts_at, duration_min, weight, kind, note, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
|
||||
for (const e of exams) {
|
||||
const date = schoolDay(e.dayOffset)
|
||||
date.setHours(e.hour, 0, 0, 0)
|
||||
const now = nowIso()
|
||||
insert.run(
|
||||
classId, e.subject, e.teacher, e.room, e.chapter,
|
||||
date.toISOString(), e.durationMin, e.weight, e.kind, e.note,
|
||||
adminId, now, now,
|
||||
)
|
||||
}
|
||||
|
||||
return { classId, adminToken }
|
||||
})
|
||||
|
||||
const { adminToken } = seed()
|
||||
|
||||
console.log('Seed terminé.')
|
||||
console.log(` Classe : ${CLASS_NAME} (${LEVEL})`)
|
||||
console.log(` Code invite : ${CLASS_CODE}`)
|
||||
console.log(` Admin : ${ADMIN_NICK}`)
|
||||
console.log(` Évaluations : ${exams.length}`)
|
||||
console.log(` Jeton admin : ${adminToken}`)
|
||||
20
server/tsconfig.json
Normal file
20
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue