@mostajs/school 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog — @mostajs/school
2
+ ## [0.1.0] — 2026-06-18
3
+ ### Added
4
+ - Inscription avec **prise de photo** + enrôlement visage (carte adhérent = qrCode auto).
5
+ - **Accès au cours par carte QR ou reconnaissance faciale** (`access.byCard`/`byFace`) → présence ; terminal PWA (examples/access-terminal, doc 12-DOC-PWA-IDENTIFICATION) comme SecuAccessPro.
6
+ - Cœur mince établissement : admit (élève+parent via users), enroll/attendance/grade/certificate/coaching (composition), parentPortal agrégé, dashboard.
7
+ - Assemble P2 ATC (6 modules). 3 tests d'assemblage + exemple §12 (parcours complet Anglais A1).
package/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @mostajs/school
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Licence** : AGPL-3.0-or-later · **Statut** : 0.1.0 (3 tests verts)
3
+ > **Cœur métier mince** P2 ATC : élève/parent (dérive @mostajs/users) + scolarité. **Compose** training/attendance/gradebook/certificates/coaching.
4
+ ```js
5
+ import { createSchool, createMemoryRepositories } from '@mostajs/school';
6
+ const school = createSchool({ repositories: createMemoryRepositories(), users, training, attendance, gradebook, certificates, coaching });
7
+ const { student } = await school.students.admit({ person:{firstName:'Inès'}, parent:{firstName:'Mme',email:'p@x.dz'}, grade:'A1' });
8
+ await school.enroll(student.id, sessionId);
9
+ await school.markAttendance(student.id, sessionId, { status:'present' });
10
+ await school.recordGrade(student.id, { sessionId, courseId, levelId, score:17, max:20 });
11
+ await school.issueCertificate(student.id, { title:'Anglais A1' });
12
+ const portal = await school.parentPortal(student.id); // présences + notes + attestations
13
+ ```
14
+ Lancer : `node test-scripts/unit/school.test.mjs && node examples/scolarite/run.mjs`
@@ -0,0 +1,13 @@
1
+ # @mostajs/school — DEVRULES de bout en bout (§4 + #3 condensés)
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Date** : 2026-06-18 · **Statut** : cas C — **cœur métier MINCE** P2 ATC.
3
+ ## §4 (règle d'or / motif « domaine mince »)
4
+ school n'implémente QUE le domaine « élève/parent + scolarité ». L'élève **DÉRIVE de `@mostajs/users`** (la personne :
5
+ nom/photo/faceDescriptor) ; tout le reste est **composé** (training/attendance/gradebook/certificates/coaching/payment/reporting).
6
+ C'est exactement le motif de `@mostajs/crm` (domaine mince) confirmé par l'audit `AUDIT-BESOINS-3P-vs-MODULES-18062026.md`.
7
+ ## §3 (périmètre/API)
8
+ **Entité** : Student{userId(→users), parentUserId(→users, mineur), enrollmentNo, grade, status} — RÉFÉRENCE la personne.
9
+ **API** : `createSchool({repositories, users, training?, attendance?, gradebook?, certificates?, coaching?, numbering?})`
10
+ → `students.{admit({person,parent,grade}), get, byUser, list, withPerson}`, `enroll(studentId,sessionId)`, `markAttendance`, `recordGrade`, `issueCertificate`, `openCoaching`, `parentPortal(studentId)`, `dashboard()`.
11
+ **Composition (§10)** : users (requis) ; training/attendance/gradebook/certificates/coaching (injectés, erreur claire si manquants). KPIs détaillés → reporting.
12
+ **Couvre P2** : F1(élève/parent) + assemble F2/F3(training), F4(attendance), F7(gradebook/questa), F13(certificates), F9(coaching), F6(portail parents). Reste : school-billing (F5, mince/ext subscriptions).
13
+ ## §4 (tests) : admit (users+parent+n°), composition requise, parcours complet enroll→présence→note→certificat→portail. (3 verts)
@@ -0,0 +1,32 @@
1
+ # 12 — Identification via PWA (carte QR / reconnaissance faciale) — comme SecuAccessPro
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · **Date** : 2026-06-18
3
+ **Principe** : l'identification d'accès au cours réutilise **la même stack que SecuAccessPro** — `@mostajs/pwa-scan`
4
+ (scanner QR PWA) + `@mostajs/face` (reconnaissance faciale) côté front → l'API `school.access.{byCard,byFace}` côté back.
5
+ Le cœur `@mostajs/school` ne réimplémente rien : il **compose** users (carte/qrCode + matchFace) + attendance (présence).
6
+
7
+ ## Flux
8
+ ```
9
+ PWA (terminal d'accès, navigateur) Backend (server.mjs, modules réels)
10
+ <PwaScanner> (QR) ──POST /api/access/scan {code}────► school.access.byCard(code,{sessionId})
11
+ → users(qrCode) → student → attendance.mark('qr')
12
+ <FaceDetector> (caméra) ─POST /api/access/face {descriptor}► school.access.byFace(desc,{sessionId})
13
+ → users.matchFace (@mostajs/face) → student → attendance.mark('face')
14
+ ◄── { status:'granted', data:student } / { matched:true, member, distance }
15
+ ```
16
+
17
+ ## Backend (vérifié)
18
+ `examples/access-terminal/server.mjs` (modules réels) :
19
+ - `GET /api/students` → élèves + **carte adhérent** (qrCode).
20
+ - `POST /api/access/scan {code}` → `school.access.byCard` → `{status:'granted'|'denied', data}`.
21
+ - `POST /api/access/face {descriptor[128]}` → `school.access.byFace` → `{matched, member, distance}`.
22
+ Lancer : `node examples/access-terminal/server.mjs` (curl-testable). Vérifié : QR accordé/refusé, visage matché (dist≈0.023)/refusé.
23
+
24
+ ## Front PWA (même gabarit que SecuAccessPro)
25
+ Réutiliser le gabarit `mostajs/mosta-pwa-scan/examples/access-pwa` (front React zéro-build) en pointant les endpoints
26
+ sur ce backend : `<PwaScanner serverUrl scanEndpoint="/api/access/scan">` + `<FaceDetector onCapture=...→/api/access/face>`.
27
+ Les composants `PwaScanner`/`FaceDetector` et le proxy modèles face-api y sont déjà câblés.
28
+
29
+ ## Composition (§10) — aucune réimplémentation
30
+ `@mostajs/pwa-scan` (scanner) · `@mostajs/face` (visage, via `users.matchFace`) · `@mostajs/users` (carte qrCode) ·
31
+ `@mostajs/attendance` (présence) · `@mostajs/school` (orchestration `access.byCard`/`byFace`). Identique à SecuAccessPro,
32
+ mais en briques composables (SecuAccessPro devrait à terme migrer sur `users`/`allocations`/`attendance`).
@@ -0,0 +1,10 @@
1
+ # Terminal d'accès SCHOOL (PWA) — carte QR / reconnaissance faciale
2
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
3
+ Identification d'accès au cours **comme SecuAccessPro**, en composant `@mostajs/pwa-scan` + `@mostajs/face` + `school.access`.
4
+ ## Lancer le backend
5
+ ```bash
6
+ node server.mjs # http://localhost:8000 ; API /api/students, /api/access/scan, /api/access/face
7
+ ```
8
+ ## Front PWA
9
+ Réutiliser `mostajs/mosta-pwa-scan/examples/access-pwa` (PwaScanner + FaceDetector) en pointant sur ce backend.
10
+ Détails : `../../docs/12-DOC-PWA-IDENTIFICATION.md`.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Terminal d'accès SCHOOL (comme SecuAccessPro) — backend Node, modules RÉELS. Author: Dr Hamid MADANI <drmdh@msn.com>
3
+ // Identification par CARTE QR ou RECONNAISSANCE FACIALE → school.access.byCard / byFace → présence.
4
+ // Front PWA = @mostajs/pwa-scan (PwaScanner) + @mostajs/face (FaceDetector) → POST ci-dessous (cf. 12-DOC-PWA-IDENTIFICATION.md).
5
+ import { createServer } from 'node:http';
6
+ import { createSchool, createMemoryRepositories } from '../../src/index.js';
7
+ import { createUsers, createMemoryRepositories as uRepo } from '../../../mosta-users-stack/mosta-users/src/index.js';
8
+ import { createTraining, createMemoryRepositories as tRepo } from '../../../mosta-training/src/index.js';
9
+ import { createAttendance, createMemoryRepositories as aRepo } from '../../../mosta-attendance/src/index.js';
10
+ import { findMatch } from '../../../mosta-face/dist/lib/face-matcher.js';
11
+
12
+ const PORT = Number(process.argv[2]) || 8000;
13
+ const desc = (s) => Array.from({ length: 128 }, (_, i) => Math.sin(s * (i + 1)) * 0.5);
14
+
15
+ // ── Stack réelle ──
16
+ const users = createUsers({ repositories: uRepo(), face: { findMatch }, qr: { generate: (id) => `ATC:${id}` } });
17
+ const training = createTraining({ repositories: tRepo() });
18
+ const attendance = createAttendance({ repositories: aRepo(), users });
19
+ const school = createSchool({ repositories: createMemoryRepositories(), users, training, attendance, numbering: { next: () => 'ELV-' + Math.floor(Math.random() * 9999) } });
20
+
21
+ let SESSION;
22
+ async function seed() {
23
+ const c = await training.catalog.createCourse({ title: 'Anglais A1' });
24
+ SESSION = await training.sessions.open({ courseId: c.id, capacity: 30, room: 'Salle 3' });
25
+ for (const [n, seed] of [[['Inès', 'Mansouri'], 1], [['Karim', 'Saidi'], 2]]) {
26
+ const { student } = await school.students.admit({ person: { firstName: n[0], lastName: n[1], faceDescriptor: desc(seed) }, grade: 'A1' });
27
+ await school.enroll(student.id, SESSION.id);
28
+ }
29
+ }
30
+ const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'access-control-allow-origin': '*' }); res.end(JSON.stringify(obj)); };
31
+ const body = (req) => new Promise((r) => { let b = ''; req.on('data', (c) => b += c); req.on('end', () => { try { r(JSON.parse(b || '{}')); } catch { r({}); } }); });
32
+ const publicStudent = async (s) => { const u = await users.get(s.userId); return { id: s.id, name: users.fullName(u), card: u.qrCode, grade: s.grade }; };
33
+
34
+ await seed();
35
+ createServer(async (req, res) => {
36
+ const url = (req.url || '/').split('?')[0];
37
+ if (req.method === 'OPTIONS') return json(res, 204, {});
38
+ try {
39
+ if (url === '/api/students' && req.method === 'GET') return json(res, 200, { students: await Promise.all((await school.students.list()).map(publicStudent)), session: SESSION.id });
40
+ if (url === '/api/access/scan' && req.method === 'POST') { // carte adhérent QR (@mostajs/pwa-scan)
41
+ const { code } = await body(req);
42
+ const r = await school.access.byCard(code, { sessionId: SESSION.id });
43
+ return json(res, 200, r.granted ? { status: 'granted', message: `Accès — ${(await publicStudent(r.student)).name}`, data: await publicStudent(r.student) } : { status: 'denied', message: r.reason });
44
+ }
45
+ if (url === '/api/access/face' && req.method === 'POST') { // reconnaissance faciale (@mostajs/face)
46
+ const { descriptor } = await body(req);
47
+ if (!Array.isArray(descriptor)) return json(res, 400, { error: 'descriptor requis' });
48
+ const r = await school.access.byFace(descriptor, { sessionId: SESSION.id });
49
+ return json(res, 200, r.granted ? { matched: true, member: await publicStudent(r.student), distance: r.distance } : { matched: false });
50
+ }
51
+ json(res, 404, { error: 'not found' });
52
+ } catch (e) { json(res, 500, { error: e.message }); }
53
+ }).listen(PORT, () => {
54
+ console.log(`🛂 Terminal d'accès SCHOOL — http://localhost:${PORT}`);
55
+ console.log(` API : GET /api/students · POST /api/access/scan {code} · POST /api/access/face {descriptor[128]}`);
56
+ console.log(` Front PWA : @mostajs/pwa-scan + @mostajs/face (cf. ../../docs/12-DOC-PWA-IDENTIFICATION.md ; gabarit : mosta-pwa-scan/examples/access-pwa)`);
57
+ });
@@ -0,0 +1,40 @@
1
+ // Exemple §12 — ATC : inscription+photo → accès cours par carte QR ou visage. node examples/scolarite/run.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { createSchool, createMemoryRepositories } from '../../src/index.js';
4
+ import { createUsers, createMemoryRepositories as uRepo } from '../../../mosta-users-stack/mosta-users/src/index.js';
5
+ import { createTraining, createMemoryRepositories as tRepo } from '../../../mosta-training/src/index.js';
6
+ import { createAttendance, createMemoryRepositories as aRepo } from '../../../mosta-attendance/src/index.js';
7
+ import { createGradebook, createMemoryRepositories as gRepo } from '../../../mosta-gradebook/src/index.js';
8
+ import { createCertificates, createMemoryRepositories as cRepo } from '../../../mosta-certificates/src/index.js';
9
+ import { hmacSha256 } from '../../../mosta-security/src/index.js';
10
+ import { findMatch } from '../../../mosta-face/dist/lib/face-matcher.js';
11
+ const desc=(s)=>Array.from({length:128},(_,i)=>Math.sin(s*(i+1))*0.5);
12
+ const users=createUsers({ repositories:uRepo(), face:{ findMatch }, qr:{generate:(id)=>'BADGE:'+id} });
13
+ const training=createTraining({ repositories:tRepo() });
14
+ const attendance=createAttendance({ repositories:aRepo(), users });
15
+ const gradebook=createGradebook({ repositories:gRepo() });
16
+ const certificates=createCertificates({ repositories:cRepo(), hmac:hmacSha256, secret:'atc', qr:{generate:(p)=>'verify?c='+p} });
17
+ const school=createSchool({ repositories:createMemoryRepositories(), users, training, attendance, gradebook, certificates, numbering:{next:()=>'ELV-2026-'+Math.floor(Math.random()*999)} });
18
+
19
+ const eng=await training.catalog.createCourse({ title:'Anglais', category:'langues' });
20
+ const a1=await training.catalog.addLevel(eng.id,{ order:1, label:'A1' });
21
+ const grp=await training.sessions.open({ courseId:eng.id, levelId:a1.id, capacity:12, room:'Salle 3' });
22
+
23
+ // 1) Inscription AVEC prise de photo + enrôlement visage (→ carte adhérent QR auto)
24
+ const { student, card }=await school.students.admit({
25
+ person:{ firstName:'Inès', lastName:'Mansouri', photo:'data:image/jpeg;base64,…', faceDescriptor:desc(1) },
26
+ parent:{ firstName:'Mme', lastName:'Mansouri', email:'parent@ex.dz' }, grade:'A1' });
27
+ await school.enroll(student.id, grp.id);
28
+ console.log(' inscrit %s · carte adhérent: %s · photo ✓', (await school.students.withPerson(student.id)).person.name, card);
29
+
30
+ // 2) Accès au cours — voie A : carte QR
31
+ const accCard=await school.access.byCard(card, { sessionId:grp.id });
32
+ // 3) Accès au cours — voie B : reconnaissance faciale (autre séance/jour)
33
+ const grp2=await training.sessions.open({ courseId:eng.id, levelId:a1.id, capacity:12 });
34
+ await school.enroll(student.id, grp2.id);
35
+ const accFace=await school.access.byFace(desc(1).map(x=>x+0.002), { sessionId:grp2.id });
36
+
37
+ assert.equal(accCard.granted && accCard.method==='qr', true);
38
+ assert.equal(accFace.granted && accFace.method==='face', true);
39
+ console.log('✅ school — accès cours : carte QR ✓ (présence %s) · visage ✓ (dist %s)',
40
+ accCard.attendance.status, accFace.distance.toFixed(3));
package/llms.txt ADDED
@@ -0,0 +1,19 @@
1
+ # @mostajs/school — fiche LLM
2
+ RÔLE
3
+ Cœur métier MINCE d'un établissement de formation (P2 ATC). Domaine : élève/parent + scolarité.
4
+ L'élève DÉRIVE de @mostajs/users. COMPOSE (injectés) training/attendance/gradebook/certificates/coaching ; KPIs→reporting. Motif « domaine mince » (cf. crm).
5
+ EXPORTS
6
+ createSchool({ repositories, users(requis), training?, attendance?, gradebook?, certificates?, coaching?, numbering?, now? }) -> api
7
+ createMemoryRepositories(); StudentSchema
8
+ API
9
+ students.admit({person:{...,photo?,faceDescriptor?},parent?,grade}) -> {student,user,parent,card(=qrCode)}
10
+ students.capturePhoto(studentId,{photo,faceDescriptor}) ; students.card(studentId) (photo+QR via users.cardData)
11
+ access.byCard(qrCode,{sessionId}) -> {granted,student,method:'qr',attendance} (carte adhérent)
12
+ access.byFace(descriptor,{sessionId,threshold}) -> {granted,student,distance,method:'face',attendance} (compose @mostajs/face via users.matchFace)
13
+ students.admit({person,parent?,grade}) -> {student,user,parent} ; students.get/byUser/list/withPerson
14
+ enroll(studentId,sessionId)[training] ; markAttendance(studentId,sessionId,{status,method})[attendance]
15
+ recordGrade(studentId,{sessionId,courseId,levelId,score,max})[gradebook] ; issueCertificate(studentId,{title,payload})[certificates]
16
+ openCoaching(studentId,{objective,coachId})[coaching] ; parentPortal(studentId)->{student,attendance,grades,certificates} ; dashboard()
17
+ PIÈGES
18
+ - users REQUIS (la personne = User ; Student n'est qu'un lien userId + champs scolaires). Modules composés injectés → erreur explicite si absents.
19
+ - Student.userId/parentUserId opaques. parentPortal = lecture seule agrégée. Ne réimplémente AUCUNE brique (compose).
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@mostajs/school",
3
+ "version": "0.1.0",
4
+ "description": "Cœur métier MINCE d'un établissement de formation : élève/parent (dérive @mostajs/users) + scolarité. COMPOSE training/attendance/gradebook/certificates/coaching. Assemble P2 ATC.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./schemas": "./src/schemas.js"
12
+ },
13
+ "keywords": [
14
+ "mostajs",
15
+ "school",
16
+ "ecole",
17
+ "scolarite",
18
+ "eleve",
19
+ "atc"
20
+ ],
21
+ "scripts": {
22
+ "test": "node test-scripts/unit/school.test.mjs",
23
+ "example": "node examples/scolarite/run.mjs"
24
+ }
25
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @mostajs/school — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ export { createSchool } from './school.js';
3
+ export { createMemoryRepositories } from './memory-repo.js';
4
+ export { StudentSchema } from './schemas.js';
@@ -0,0 +1,6 @@
1
+ function coll(){ const m=new Map(); return {
2
+ async create(d){ const id=d.id||globalThis.crypto.randomUUID(); const now=new Date(); const r={id,createdAt:now,updatedAt:now,...d}; m.set(id,r); return {...r}; },
3
+ async findById(id){ const r=m.get(id); return r?{...r}:null; },
4
+ async update(id,p){ const r=m.get(id); if(!r)return null; const x={...r,...p,updatedAt:new Date()}; m.set(id,x); return {...x}; },
5
+ async find(f=()=>true){ return [...m.values()].filter(f).map(r=>({...r})); } }; }
6
+ export function createMemoryRepositories(){ return { students:coll() }; }
package/src/schemas.js ADDED
@@ -0,0 +1,7 @@
1
+ // @mostajs/school — schéma mince (le profil personne vit dans @mostajs/users). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Student RÉFÉRENCE un User (élève) + un User (parent, mineur). Conceptuellement : élève DÉRIVE de User (users.extendUser 'student').
3
+ export const StudentSchema = { name:'Student', collection:'students', timestamps:true, fields:{
4
+ userId:{type:'string',required:true}, // → @mostajs/users (la personne : nom, photo, faceDescriptor…)
5
+ parentUserId:{type:'string',default:null}, // → @mostajs/users (tuteur, mineur)
6
+ enrollmentNo:{type:'string'}, grade:{type:'string'}, status:{type:'string',enum:['active','left'],default:'active'} },
7
+ indexes:[{fields:{userId:'asc'}}] };
package/src/school.js ADDED
@@ -0,0 +1,83 @@
1
+ // @mostajs/school — cœur métier MINCE (établissement de formation). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // N'implémente QUE le domaine « élève/parent + scolarité + accès ». COMPOSE les briques (injectées) :
3
+ // users (personne — l'élève DÉRIVE de User : photo, faceDescriptor, qrCode/carte), training (sessions/inscriptions),
4
+ // attendance (présences), gradebook (notes), certificates (attestations), coaching (suivi). Motif « domaine mince » de @mostajs/crm.
5
+
6
+ export function createSchool({ repositories, users, training, attendance, gradebook, certificates, coaching, numbering, now = () => new Date() } = {}) {
7
+ if (!repositories?.students) throw new Error('createSchool: repositories.students requis');
8
+ if (!users) throw new Error('createSchool: @mostajs/users requis (la personne dérive de User)');
9
+ const { students } = repositories;
10
+ const enrollNo = () => (numbering?.next ? numbering.next('student') : `ELV-${now().getFullYear()}-${Math.floor(now().getTime() % 100000)}`);
11
+ const need = (m, name) => { if (!m) throw new Error(`@mostajs/${name} requis (composition)`); return m; };
12
+
13
+ const api = {
14
+ students: {
15
+ /** Admet un élève : crée la PERSONNE (users : nom + PHOTO + faceDescriptor + qrCode/carte) + le parent + le profil scolaire. */
16
+ async admit({ person = {}, parent = null, grade } = {}) {
17
+ if (!person.firstName && !person.lastName && !person.username) throw new Error('person (élève) requise');
18
+ const parentUser = parent ? await users.create({ ...parent }) : null;
19
+ const studentUser = await users.create({ ...person }); // person peut porter photo + faceDescriptor → carte qrCode auto
20
+ const student = await students.create({ userId: studentUser.id, parentUserId: parentUser?.id || null, grade, enrollmentNo: enrollNo() });
21
+ return { student, user: studentUser, parent: parentUser, card: studentUser.qrCode };
22
+ },
23
+ /** Prise de photo / enrôlement visage (post-admission). */
24
+ async capturePhoto(studentId, { photo, faceDescriptor } = {}) {
25
+ const s = await students.findById(studentId); if (!s) throw new Error('élève introuvable');
26
+ if (photo) await users.setPhoto(s.userId, photo);
27
+ if (Array.isArray(faceDescriptor)) await users.enrollFace(s.userId, faceDescriptor);
28
+ return users.get(s.userId);
29
+ },
30
+ /** Carte adhérent (photo + QR). Compose @mostajs/users (cardData → qrpanel côté rendu). */
31
+ async card(studentId) { const s = await students.findById(studentId); return users.cardData ? users.cardData(s.userId) : { id: s.userId }; },
32
+ get: (id) => students.findById(id),
33
+ byUser: async (userId) => (await students.find((s) => s.userId === userId))[0] || null,
34
+ list: () => students.find(),
35
+ async withPerson(id) {
36
+ const s = await students.findById(id); if (!s) return null;
37
+ const u = await users.get(s.userId); const p = s.parentUserId ? await users.get(s.parentUserId) : null;
38
+ return { ...s, person: u && { name: users.fullName(u), email: u.email, photo: u.photo }, parent: p && { name: users.fullName(p), email: p.email } };
39
+ },
40
+ },
41
+
42
+ // ── Scolarité ──
43
+ async enroll(studentId, sessionId) { const s = await students.findById(studentId); return need(training, 'training').enrollments.enroll(sessionId, s.userId); },
44
+ async markAttendance(studentId, sessionId, opts = {}) { const s = await students.findById(studentId); return need(attendance, 'attendance').mark(sessionId, s.userId, opts); },
45
+ async recordGrade(studentId, dto) { const s = await students.findById(studentId); return need(gradebook, 'gradebook').record(s.userId, dto); },
46
+ async issueCertificate(studentId, dto) { const s = await students.findById(studentId); return need(certificates, 'certificates').issue({ subjectId: s.userId, ...dto }); },
47
+ async openCoaching(studentId, { objective, coachId } = {}) { const s = await students.findById(studentId); return need(coaching, 'coaching').plans.create({ coacheeId: s.userId, coachId, objective }); },
48
+
49
+ // ── Accès au cours : carte QR OU reconnaissance faciale → pointage de présence ──
50
+ access: {
51
+ /** Accès par carte adhérent (QR). Identifie via users(qrCode) → marque présence. */
52
+ async byCard(qrCode, { sessionId, mark = true } = {}) {
53
+ const u = (await users.repositories.users.find((x) => x.qrCode === qrCode))[0];
54
+ if (!u) return { granted: false, reason: 'carte inconnue' };
55
+ const st = await api.students.byUser(u.id); if (!st) return { granted: false, reason: 'non inscrit' };
56
+ const att = (sessionId && mark && attendance) ? await attendance.mark(sessionId, u.id, { status: 'present', method: 'qr' }) : null;
57
+ return { granted: true, student: st, user: u, method: 'qr', attendance: att };
58
+ },
59
+ /** Accès par reconnaissance faciale. Identifie via users.matchFace (@mostajs/face) → marque présence. */
60
+ async byFace(descriptor, { sessionId, mark = true, threshold } = {}) {
61
+ if (!users.matchFace) throw new Error('users.matchFace requis (@mostajs/face injecté dans users)');
62
+ const m = await users.matchFace(descriptor, { threshold });
63
+ if (!m) return { granted: false, reason: 'visage inconnu' };
64
+ const st = await api.students.byUser(m.match.id); if (!st) return { granted: false, reason: 'non inscrit' };
65
+ const att = (sessionId && mark && attendance) ? await attendance.mark(sessionId, m.match.id, { status: 'present', method: 'face' }) : null;
66
+ return { granted: true, student: st, distance: m.distance, method: 'face', attendance: att };
67
+ },
68
+ },
69
+
70
+ async parentPortal(studentId) {
71
+ const s = await students.findById(studentId); if (!s) return null;
72
+ return {
73
+ student: await api.students.withPerson(studentId),
74
+ attendance: attendance ? await attendance.listByLearner(s.userId) : [],
75
+ grades: gradebook ? await gradebook.average(s.userId, {}) : null,
76
+ certificates: certificates ? await certificates.listBySubject(s.userId) : [],
77
+ };
78
+ },
79
+ async dashboard() { return { students: (await students.find()).length }; },
80
+ repositories,
81
+ };
82
+ return api;
83
+ }
@@ -0,0 +1,65 @@
1
+ // @mostajs/school — tests d'ASSEMBLAGE + accès (DEVRULES §5). node test-scripts/unit/school.test.mjs
2
+ import assert from 'node:assert/strict';
3
+ import { createSchool, createMemoryRepositories } from '../../src/index.js';
4
+ import { createUsers, createMemoryRepositories as uRepo } from '../../../mosta-users-stack/mosta-users/src/index.js';
5
+ import { createTraining, createMemoryRepositories as tRepo } from '../../../mosta-training/src/index.js';
6
+ import { createAttendance, createMemoryRepositories as aRepo } from '../../../mosta-attendance/src/index.js';
7
+ import { createGradebook, createMemoryRepositories as gRepo } from '../../../mosta-gradebook/src/index.js';
8
+ import { createCertificates, createMemoryRepositories as cRepo } from '../../../mosta-certificates/src/index.js';
9
+ import { hmacSha256 } from '../../../mosta-security/src/index.js';
10
+ import { findMatch } from '../../../mosta-face/dist/lib/face-matcher.js';
11
+ let pass=0; const test=async(n,f)=>{await f();pass++;console.log(' ✓',n);};
12
+ const desc=(s)=>Array.from({length:128},(_,i)=>Math.sin(s*(i+1))*0.5);
13
+ function wire(){
14
+ const users=createUsers({ repositories:uRepo(), face:{ findMatch }, qr:{generate:(id)=>'BADGE:'+id} });
15
+ const training=createTraining({ repositories:tRepo() });
16
+ const attendance=createAttendance({ repositories:aRepo(), users });
17
+ const gradebook=createGradebook({ repositories:gRepo() });
18
+ const certificates=createCertificates({ repositories:cRepo(), hmac:hmacSha256, secret:'k', qr:{generate:(p)=>'QR:'+p} });
19
+ const school=createSchool({ repositories:createMemoryRepositories(), users, training, attendance, gradebook, certificates });
20
+ return { users, training, attendance, gradebook, certificates, school };
21
+ }
22
+
23
+ await test('admit avec PHOTO + visage + carte (dérive User)', async()=>{
24
+ const { school }=wire();
25
+ const r=await school.students.admit({ person:{firstName:'Inès',lastName:'M',photo:'data:photo',faceDescriptor:desc(1)}, parent:{firstName:'P',email:'p@x.dz'}, grade:'A1' });
26
+ assert.ok(r.card.startsWith('BADGE:'), 'carte = qrCode auto');
27
+ const card=await school.students.card(r.student.id); assert.equal(card.photo,'data:photo'); assert.ok(card.qr);
28
+ });
29
+ await test('capturePhoto post-admission', async()=>{
30
+ const { school, users }=wire(); const r=await school.students.admit({ person:{firstName:'A'} });
31
+ await school.students.capturePhoto(r.student.id,{ photo:'data:p2', faceDescriptor:desc(2) });
32
+ const u=await users.get(r.user.id); assert.equal(u.photo,'data:p2'); assert.equal(u.faceDescriptor.length,128);
33
+ });
34
+ await test('ACCÈS par carte QR → présence', async()=>{
35
+ const { training, school }=wire();
36
+ const c=await training.catalog.createCourse({title:'Anglais'}); const ses=await training.sessions.open({courseId:c.id,capacity:10});
37
+ const r=await school.students.admit({ person:{firstName:'Inès'} }); await school.enroll(r.student.id, ses.id);
38
+ const acc=await school.access.byCard(r.card, { sessionId:ses.id });
39
+ assert.equal(acc.granted,true); assert.equal(acc.method,'qr'); assert.equal(acc.attendance.status,'present');
40
+ assert.equal((await school.access.byCard('BADGE:inconnu',{sessionId:ses.id})).granted,false);
41
+ });
42
+ await test('ACCÈS par reconnaissance faciale → présence', async()=>{
43
+ const { training, school }=wire();
44
+ const c=await training.catalog.createCourse({title:'X'}); const ses=await training.sessions.open({courseId:c.id,capacity:10});
45
+ const r=await school.students.admit({ person:{firstName:'Inès',faceDescriptor:desc(1)} }); await school.enroll(r.student.id, ses.id);
46
+ const acc=await school.access.byFace(desc(1).map(x=>x+0.002), { sessionId:ses.id });
47
+ assert.equal(acc.granted,true); assert.equal(acc.method,'face'); assert.equal(acc.attendance.method,'face');
48
+ assert.equal((await school.access.byFace(desc(99),{sessionId:ses.id})).granted,false);
49
+ });
50
+ await test('parcours complet : enroll→note→certificat→portail parent', async()=>{
51
+ const { training, school }=wire();
52
+ const c=await training.catalog.createCourse({title:'Anglais'}); const lvl=await training.catalog.addLevel(c.id,{order:1,label:'A1'});
53
+ const ses=await training.sessions.open({courseId:c.id,levelId:lvl.id,capacity:10});
54
+ const r=await school.students.admit({ person:{firstName:'Inès'}, parent:{firstName:'P',email:'p@x.dz'} });
55
+ await school.enroll(r.student.id, ses.id);
56
+ await school.recordGrade(r.student.id,{ sessionId:ses.id, courseId:c.id, levelId:lvl.id, kind:'exam', score:16, max:20 });
57
+ const cert=await school.issueCertificate(r.student.id,{ title:'Anglais A1' });
58
+ const portal=await school.parentPortal(r.student.id);
59
+ assert.equal(portal.grades.percent,80); assert.equal(portal.certificates[0].no,cert.no);
60
+ });
61
+ await test('composition requise (erreur claire)', async()=>{
62
+ const users=createUsers({ repositories:uRepo() }); const school=createSchool({ repositories:createMemoryRepositories(), users });
63
+ const r=await school.students.admit({ person:{firstName:'A'} }); await assert.rejects(()=>school.enroll(r.student.id,'s'),/training requis/);
64
+ });
65
+ console.log(`\n✅ @mostajs/school — ${pass} tests OK`);