v0.4.0 — gestion des admins (promotion / rétrogradation)

- DB : colonne is_creator sur members + migration automatique
- API : GET /api/config/members (liste admin), PATCH /api/config/members/:id (rôle)
- Règles : créateur non rétrogradable, admin non auto-rétrogradable, dernier admin protégé
- Front : panneau 'Membres de la classe' dans l'onglet Plus (admin)
- Chaque membre affiche son badge (Créateur / Admin / Élève) + boutons promote/demote
This commit is contained in:
Mathis 2026-10-04 17:46:12 +02:00
parent a7c4740d02
commit 4e9b820686
6 changed files with 263 additions and 14 deletions

View file

@ -9,6 +9,7 @@ interface MemberRow {
class_id: number
nickname: string
role: Role
is_creator: number
class_name: string
class_level: string | null
class_flash_message: string | null
@ -47,11 +48,11 @@ export function createClass(name: string, level: string | null, nickname: string
.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
.prepare('INSERT INTO members (class_id, nickname, role, is_creator, token, created_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(classId, nickname, 'admin', 1, token, now).lastInsertRowid as number
return {
token,
member: { id: memberId, classId, nickname, role: 'admin' },
member: { id: memberId, classId, nickname, role: 'admin', isCreator: true },
klass: { id: classId, name, level, flashMessage: null },
}
})
@ -71,7 +72,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
.run(row.id, nickname, 'student', token, nowIso()).lastInsertRowid as number
return {
token,
member: { id: memberId, classId: row.id, nickname, role: 'student' },
member: { id: memberId, classId: row.id, nickname, role: 'student', isCreator: false },
klass: { id: row.id, name: row.name, level: row.level, flashMessage: null },
}
}
@ -79,7 +80,7 @@ export function joinClass(code: string, nickname: string): NewSession | null {
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,
`SELECT m.id, m.class_id, m.nickname, m.role, m.is_creator,
c.name AS class_name, c.level AS class_level,
c.flash_message AS class_flash_message
FROM members m
@ -89,7 +90,7 @@ export function findSession(token: string): { member: Member; klass: ClassInfo }
.get(token)
if (!row) return null
return {
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role },
member: { id: row.id, classId: row.class_id, nickname: row.nickname, role: row.role, isCreator: row.is_creator === 1 },
klass: { id: row.class_id, name: row.class_name, level: row.class_level, flashMessage: row.class_flash_message },
}
}

View file

@ -22,12 +22,13 @@ db.exec(`
);
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
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')),
is_creator INTEGER NOT NULL DEFAULT 0,
token TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_members_token ON members(token);
@ -68,6 +69,20 @@ try {
// colonne déjà présente : rien à faire
}
// Migration : is_creator sur members (le premier admin d'une classe = créateur).
try {
db.exec(`ALTER TABLE members ADD COLUMN is_creator INTEGER NOT NULL DEFAULT 0`)
// Marquer le premier membre de chaque classe comme créateur.
db.exec(`
UPDATE members SET is_creator = 1
WHERE id = (
SELECT MIN(m2.id) FROM members m2 WHERE m2.class_id = members.class_id
)
`)
} 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

@ -1,6 +1,6 @@
import { Router } from 'express'
import { z } from 'zod'
import type { ClassInfo, DayWeather, FlashInfo, WeatherKind } from '@meteo-exam/shared'
import type { ClassInfo, DayWeather, FlashInfo, Role, WeatherKind } from '@meteo-exam/shared'
import { isWeatherKind } from '@meteo-exam/shared'
import { db, nowIso } from '../db.js'
import { requireSession, requireAdmin } from '../auth.js'
@ -142,3 +142,111 @@ configRouter.patch('/class', requireSession, requireAdmin, (req, res) => {
broadcast(classId, { type: 'class.updated', klass })
res.json(klass)
})
// --- Gestion des membres (promotion / rétrogradation) ------------------------
interface MemberListRow {
id: number
nickname: string
role: Role
is_creator: number
created_at: string
}
/** Liste les membres de la classe (réservé aux admins). */
configRouter.get('/members', requireSession, requireAdmin, (req, res) => {
const classId = req.session!.member.classId
const rows = db
.prepare<[number], MemberListRow>(
'SELECT id, nickname, role, is_creator, created_at FROM members WHERE class_id = ? ORDER BY is_creator DESC, role ASC, nickname ASC',
)
.all(classId)
res.json(
rows.map((r) => ({
id: r.id,
classId,
nickname: r.nickname,
role: r.role,
isCreator: r.is_creator === 1,
})),
)
})
const roleSchema = z.object({
role: z.enum(['admin', 'student']),
})
/** Change le rôle d'un membre. Règles :
* - le créateur ne peut pas être rétrogradé ;
* - un admin ne peut pas se rétrograder lui-même (sinon plus personne ne gère) ;
* - la dernière admin de la classe ne peut pas être rétrogradée.
*/
configRouter.patch('/members/:id', requireSession, requireAdmin, (req, res) => {
const raw = req.params['id']
const memberId = Number(Array.isArray(raw) ? raw[0] : raw)
if (!Number.isInteger(memberId)) {
res.status(400).json({ error: 'Identifiant invalide' })
return
}
const parsed = roleSchema.safeParse(req.body)
if (!parsed.success) {
res.status(400).json({ error: 'Rôle invalide' })
return
}
const classId = req.session!.member.classId
const target = db
.prepare<[number, number], MemberListRow>(
'SELECT id, nickname, role, is_creator, created_at FROM members WHERE id = ? AND class_id = ?',
)
.get(memberId, classId)
if (!target) {
res.status(404).json({ error: 'Membre introuvable' })
return
}
const newRole = parsed.data.role
const selfId = req.session!.member.id
// Le créateur reste toujours admin.
if (target.is_creator === 1 && newRole === 'student') {
res.status(403).json({ error: 'Le créateur de la classe ne peut pas être rétrogradé.' })
return
}
// Un admin ne peut pas se rétrograder soi-même.
if (memberId === selfId && newRole === 'student') {
res.status(403).json({ error: 'Vous ne pouvez pas vous rétrograder vous-même.' })
return
}
// Si on rétrograde un admin, vérifier qu'il en reste au moins un autre.
if (target.role === 'admin' && newRole === 'student') {
const adminCount = db
.prepare<[number, number], { n: number }>(
`SELECT COUNT(*) AS n FROM members WHERE class_id = ? AND role = 'admin' AND id != ?`,
)
.get(classId, memberId)!
if (adminCount.n === 0) {
res.status(403).json({ error: 'Impossible de rétrograder le dernier admin de la classe.' })
return
}
}
db.prepare('UPDATE members SET role = ? WHERE id = ? AND class_id = ?').run(newRole, memberId, classId)
const updated = db
.prepare<[number, number], MemberListRow>(
'SELECT id, nickname, role, is_creator, created_at FROM members WHERE id = ? AND class_id = ?',
)
.get(memberId, classId)!
res.json({
id: updated.id,
classId,
nickname: updated.nickname,
role: updated.role,
isCreator: updated.is_creator === 1,
})
})