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:
Mathis 2026-10-02 19:51:13 +02:00
commit 6d154ed5fb
92 changed files with 9574 additions and 0 deletions

28
server/package.json Normal file
View 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
View 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
View 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
View 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
View 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`.')
}
})

View 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
View 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()
})

View 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
View 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
View 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"]
}