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:
parent
6d154ed5fb
commit
246f989bdc
16 changed files with 782 additions and 86 deletions
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
144
server/src/routes/config.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue