v0.2.0 — config admin (météo forcée, flash info, édition classe) + PWA

- DB : table day_weather (override admin par jour), flash_message sur classes
- API : PUT/DELETE /api/config/day-weather, PUT /api/config/flash, PATCH /api/config/class
- SSE : day_weather.updated/deleted, flash.updated, class.updated
- Front : bandeau flash info, indicateur 'forcé' sur la frise, modale météo admin
- Onglet Plus : panneau admin (flash info + édition classe), tout admin modifiable
- PWA : manifest.webmanifest, service worker, meta Apple, icônes 192/512
- Web version accessible sur iOS via Safari (Ajouter à l'écran d'accueil)
This commit is contained in:
Mathis 2026-10-04 17:05:56 +02:00
parent 6d154ed5fb
commit 246f989bdc
16 changed files with 782 additions and 86 deletions

View file

@ -11,6 +11,7 @@ interface MemberRow {
role: Role
class_name: string
class_level: string | null
class_flash_message: string | null
}
export const newToken = (): string => randomBytes(32).toString('base64url')
@ -51,7 +52,7 @@ export function createClass(name: string, level: string | null, nickname: string
return {
token,
member: { id: memberId, classId, nickname, role: 'admin' },
klass: { id: classId, name, level },
klass: { id: classId, name, level, flashMessage: null },
}
})
return { code, ...create() }
@ -71,7 +72,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
return {
token,
member: { id: memberId, classId: row.id, nickname, role: 'student' },
klass: { id: row.id, name: row.name, level: row.level },
klass: { id: row.id, name: row.name, level: row.level, flashMessage: null },
}
}
@ -79,7 +80,8 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
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
c.name AS class_name, c.level AS class_level,
c.flash_message AS class_flash_message
FROM members m
JOIN classes c ON c.id = m.class_id
WHERE m.token = ?`,
@ -88,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
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 },
klass: { id: row.class_id, name: row.class_name, level: row.class_level, flashMessage: row.class_flash_message },
}
}

View file

@ -13,11 +13,12 @@ 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
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
level TEXT,
code TEXT NOT NULL UNIQUE,
flash_message TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS members (
@ -50,8 +51,23 @@ db.exec(`
);
CREATE INDEX IF NOT EXISTS idx_exams_class_start ON exams(class_id, starts_at);
CREATE TABLE IF NOT EXISTS day_weather (
class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE,
day_key TEXT NOT NULL,
kind TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (class_id, day_key)
);
`)
// Migration légère : ajouter flash_message si la table existe déjà sans.
try {
db.exec(`ALTER TABLE classes ADD COLUMN flash_message TEXT`)
} catch {
// colonne déjà présente : rien à faire
}
export const nowIso = (): string => new Date().toISOString()
/** Raccourci : préparer une requête dont `.get()` renvoie `changes` (DELETE). */

View file

@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { classesRouter } from './routes/classes.js'
import { examsRouter } from './routes/exams.js'
import { streamRouter } from './routes/stream.js'
import { configRouter } from './routes/config.js'
const here = dirname(fileURLToPath(import.meta.url))
const PORT = Number(process.env['PORT'] ?? 8787)
@ -42,6 +43,7 @@ app.get('/api/health', (_req, res) => {
app.use('/api/classes', classesRouter)
app.use('/api/exams', examsRouter)
app.use('/api/stream', streamRouter)
app.use('/api/config', configRouter)
app.use('/api', (_req, res) => {
res.status(404).json({ error: 'Route inconnue' })

144
server/src/routes/config.ts Normal file
View file

@ -0,0 +1,144 @@
import { Router } from 'express'
import { z } from 'zod'
import type { ClassInfo, DayWeather, FlashInfo, WeatherKind } from '@meteo-exam/shared'
import { isWeatherKind } from '@meteo-exam/shared'
import { db, nowIso } from '../db.js'
import { requireSession, requireAdmin } from '../auth.js'
import { broadcast } from '../events.js'
export const configRouter = Router()
// --- Override météo par jour -------------------------------------------------
const dayWeatherSchema = z.object({
dayKey: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Format AAAA-MM-JJ attendu'),
kind: z.string().refine(isWeatherKind, 'Statut météo inconnu'),
})
/** Tous les overrides météo de la classe. */
configRouter.get('/day-weather', requireSession, (req, res) => {
const classId = req.session!.member.classId
const rows = db
.prepare<[number], { day_key: string; kind: WeatherKind; updated_at: string }>(
'SELECT day_key, kind, updated_at FROM day_weather WHERE class_id = ? ORDER BY day_key ASC',
)
.all(classId)
res.json(
rows.map((r) => ({
classId,
dayKey: r.day_key,
kind: r.kind,
updatedAt: r.updated_at,
})) satisfies DayWeather[],
)
})
/** Pose (ou remplace) l'override météo d'un jour. */
configRouter.put('/day-weather', requireSession, requireAdmin, (req, res) => {
const parsed = dayWeatherSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
return
}
const { dayKey, kind } = parsed.data
const classId = req.session!.member.classId
const now = nowIso()
db.prepare(
`INSERT INTO day_weather (class_id, day_key, kind, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(class_id, day_key) DO UPDATE SET kind = excluded.kind, updated_at = excluded.updated_at`,
).run(classId, dayKey, kind, now)
const dayWeather: DayWeather = { classId, dayKey, kind, updatedAt: now }
broadcast(classId, { type: 'day_weather.updated', dayWeather })
res.json(dayWeather)
})
/** Supprime l'override d'un jour (la météo redevient celle des évaluations). */
configRouter.delete('/day-weather/:dayKey', requireSession, requireAdmin, (req, res) => {
const raw = req.params['dayKey']
const dayKey = Array.isArray(raw) ? raw[0] : raw
if (!dayKey) {
res.status(400).json({ error: 'Jour manquant' })
return
}
const classId = req.session!.member.classId
const info = db
.prepare('DELETE FROM day_weather WHERE class_id = ? AND day_key = ?')
.run(classId, dayKey)
if (info.changes === 0) {
res.status(404).json({ error: 'Aucun override pour ce jour' })
return
}
broadcast(classId, { type: 'day_weather.deleted', dayKey })
res.status(204).end()
})
// --- Flash info --------------------------------------------------------------
const flashSchema = z.object({
message: z.string().trim().max(500, 'Message trop long (500 caractères max)'),
})
configRouter.get('/flash', requireSession, (req, res) => {
const row = db
.prepare<[number], { flash_message: string | null }>(
'SELECT flash_message FROM classes WHERE id = ?',
)
.get(req.session!.member.classId)
const message = row?.flash_message ?? null
res.json({ classId: req.session!.member.classId, message, updatedAt: '' } satisfies FlashInfo)
})
/** Écrit (ou efface avec message vide) le flash info du jour. */
configRouter.put('/flash', requireSession, requireAdmin, (req, res) => {
const parsed = flashSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
return
}
const classId = req.session!.member.classId
const message = (parsed.data.message || '').trim() || null
const now = nowIso()
db.prepare('UPDATE classes SET flash_message = ? WHERE id = ?').run(message ?? '', classId)
const flash: FlashInfo = { classId, message, updatedAt: now }
broadcast(classId, { type: 'flash.updated', flash })
res.json(flash)
})
// --- Édition de la classe ----------------------------------------------------
const classSchema = z.object({
name: z.string().trim().min(2, 'Nom trop court').max(60),
level: z.string().trim().max(40).nullish(),
})
configRouter.patch('/class', requireSession, requireAdmin, (req, res) => {
const parsed = classSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Requête invalide' })
return
}
const classId = req.session!.member.classId
const { name, level } = parsed.data
db.prepare('UPDATE classes SET name = ?, level = ? WHERE id = ?').run(name, level ?? '', classId)
const row = db
.prepare<[number], { id: number; name: string; level: string | null; flash_message: string | null }>(
'SELECT id, name, level, flash_message FROM classes WHERE id = ?',
)
.get(classId)!
const klass: ClassInfo = {
id: row.id,
name: row.name,
level: row.level,
flashMessage: row.flash_message,
}
broadcast(classId, { type: 'class.updated', klass })
res.json(klass)
})

View file

@ -83,8 +83,8 @@ const exams: SeedExam[] = [
]
const seed = db.transaction(() => {
db.prepare('INSERT INTO classes (name, level, code, created_at) VALUES (?, ?, ?, ?)')
.run(CLASS_NAME, LEVEL, CLASS_CODE, nowIso())
db.prepare('INSERT INTO classes (name, level, code, flash_message, created_at) VALUES (?, ?, ?, ?, ?)')
.run(CLASS_NAME, LEVEL, CLASS_CODE, 'Bon courage pour les révisions cette semaine ! 🌤️', nowIso())
const classId = db.prepare<[string], { id: number }>('SELECT id FROM classes WHERE code = ?')
.get(CLASS_CODE)!.id