@mostajs/events 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,11 @@
1
+ # Changelog — @mostajs/events
2
+
3
+ ## [0.1.0] — 2026-06-22
4
+ ### Ajouté
5
+ - Cœur métier événements : création (organisateur externe/entreprise/Hadhinat, gratuit/payant), publication, capacité/sièges.
6
+ - Inscriptions + billets à jeton signé (HMAC) ; événement payant → compose `payment.charge`.
7
+ - Contrôle d'accès `checkin.validate` à entrée unique (badge → used ; rejeu refusé ; jeton falsifié rejeté).
8
+ - `createMemoryRepositories()` (dev/démo). 7 tests @mostajs/mjs-unit.
9
+ - Encodeur QR extrait vers `@mostajs/qrpanel/qr-svg` (pur JS, vérifié contre le vecteur de référence ISO).
10
+
11
+ [0.1.0]: #
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @mostajs/events
2
+
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
4
+
5
+ Cœur métier **mince, zéro-dépendance** de gestion d'événements (salon, atelier, conférence) : événements (organisateur externe / entreprise hébergée / Hadhinat, **gratuit ou payant**), inscriptions, **billets**, et **contrôle d'accès par QR** (invitation / badge / check-in à entrée unique). Le rendu QR est délégué à `@mostajs/qrpanel/qr-svg` (séparation domaine / présentation).
6
+
7
+ ## Aperçu
8
+
9
+ ```js
10
+ import { createEvents, createMemoryRepositories } from '@mostajs/events';
11
+ import { qrSvg } from '@mostajs/qrpanel/qr-svg';
12
+
13
+ const events = createEvents({ repositories: createMemoryRepositories(), numbering, payment, secret });
14
+ const e = await events.events.create({ title: 'Salon', organizer: { type: 'hadhinat', name: 'Hadhinat' }, capacity: 200, fee: 0 });
15
+ await events.events.publish(e.id);
16
+ const { ticket } = await events.registrations.register(e.id, { name: 'Sami', email: 'sami@x.dz' });
17
+ const badge = qrSvg(ticket.token); // QR de badge (SVG)
18
+ const r = await events.checkin.validate(ticket.token); // contrôle d'accès → { ok: true } (puis 'already_used')
19
+ ```
20
+
21
+ ## Tests
22
+ `npm test` — @mostajs/mjs-unit (domaine événements ; le QR est testé dans @mostajs/qrpanel).
23
+
24
+ ## Licence
25
+ AGPL-3.0-or-later · © Dr Hamid MADANI
package/llms.txt ADDED
@@ -0,0 +1,21 @@
1
+ # @mostajs/events
2
+
3
+ Cœur métier MINCE de gestion d'événements (salon/atelier/conférence) + contrôle d'accès par QR. Zéro-dépendance. Motif domaine mince (cf. @mostajs/incubator, @mostajs/advisory). Le RENDU QR est délégué à @mostajs/qrpanel/qr-svg (séparation domaine/présentation).
4
+
5
+ ## Façade
6
+ `createEvents({ repositories:{events,registrations,tickets}, numbering?, payment?, audit?, secret, now? })`
7
+ - `events.create({ title, organizer:{type:'external'|'company'|'hadhinat', name, companyId?}, startsAt, endsAt, venue, capacity, fee=0, description }) → event` (statut 'draft')
8
+ - `events.publish/cancel/close(id)` · `events.get(id)` · `events.list(filtre)` · `events.seats(id) → {capacity,taken,remaining}`
9
+ - `registrations.register(eventId, {name,email,phone}) → {registration, ticket, paid}` — refuse si non publié / complet / email manquant. Émet un BILLET avec jeton signé (charge utile QR badge). Événement PAYANT (fee>0) → compose `payment.charge` ; sans transport paiement → billet 'pending-payment'.
10
+ - `registrations.list(eventId)` · `registrations.cancel(id)`
11
+ - `tickets.get(id)` · `tickets.byRegistration(regId)` · `tickets.token(ticketId)`
12
+ - `checkin.validate(token) → {ok, reason?, event, registration, ticket}` — signature OK + billet valide + non utilisé → marque 'used' (entrée unique). Rejeu → reason 'already_used' ; faux → 'bad_signature' ; impayé → 'unpaid'.
13
+ - `checkin.peek(token)` (sans consommer) · `sign(payload)` / `verify(token)` (helpers QR)
14
+
15
+ ## 3 usages QR (rendus par l'app via @mostajs/qrpanel/qr-svg)
16
+ 1. **Invitation** : QR de l'URL publique de l'événement → scan = page d'inscription.
17
+ 2. **Badge** : QR du `ticket.token` (jeton signé) → présenté à l'entrée.
18
+ 3. **Contrôle d'accès** : le staff scanne le badge → `checkin.validate(token)`.
19
+
20
+ ## Composition
21
+ numbering (n° EVT/TKT) · payment (events payants) · audit. Jeton = HMAC single-purpose (node:crypto). Repositories in-mem via `createMemoryRepositories()` (DB réelle via @mostajs/orm en prod).
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@mostajs/events",
3
+ "version": "0.1.0",
4
+ "description": "Cœur métier mince de gestion d'événements (salon/atelier/conférence) : événements (organisateur externe/entreprise/Hadhinat, gratuit ou payant), inscriptions, billets, et contrôle d'accès par QR (invitation/badge/check-in). Encodeur QR→SVG zéro-dépendance inclus. Compose numbering/payment/audit.",
5
+ "license": "AGPL-3.0-or-later",
6
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "files": [
10
+ "src",
11
+ "llms.txt",
12
+ "README.md",
13
+ "CHANGELOG.md"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/index.js",
17
+ "./qr": "./src/qr.js"
18
+ },
19
+ "keywords": [
20
+ "mostajs",
21
+ "events",
22
+ "ticketing",
23
+ "qrcode",
24
+ "qr",
25
+ "checkin",
26
+ "access-control",
27
+ "incubator"
28
+ ],
29
+ "devDependencies": {
30
+ "@mostajs/mjs-unit": "^0.3.0"
31
+ },
32
+ "scripts": {
33
+ "test": "bash test-scripts/run-tests.sh"
34
+ }
35
+ }
package/src/events.js ADDED
@@ -0,0 +1,135 @@
1
+ // @mostajs/events — cœur métier MINCE de gestion d'événements (salon, atelier, conférence). Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // N'implémente QUE le domaine « événement + inscriptions + billets + contrôle d'accès par QR ». Motif domaine mince (cf. incubator/advisory).
3
+ // COMPOSE (injecté, optionnel) : numbering (n° EVT/TKT), payment (événement PAYANT → charge), audit. Le RENDU QR est délégué à l'app (token signé → image).
4
+ // Organisateur : externe | entreprise hébergée | Hadhinat. Billet GRATUIT ou PAYANT. 3 usages QR : invitation (inscription), badge (billet), contrôle d'accès (check-in).
5
+ import crypto from 'node:crypto';
6
+
7
+ const EVENT_STAGES = ['draft', 'published', 'closed', 'cancelled'];
8
+ const ORG_TYPES = ['external', 'company', 'hadhinat'];
9
+
10
+ // ── Jeton signé (HMAC single-purpose) : sert de charge utile au QR « badge » et au contrôle d'accès. ──
11
+ function signTicket(secret, payload) {
12
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
13
+ const sig = crypto.createHmac('sha256', secret).update(body).digest('base64url');
14
+ return `${body}.${sig}`;
15
+ }
16
+ function verifyTicket(secret, token) {
17
+ const parts = String(token || '').split('.');
18
+ if (parts.length !== 2) return { ok: false, reason: 'malformed' };
19
+ const [body, sig] = parts;
20
+ const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url');
21
+ const a = Buffer.from(sig, 'base64url'); const b = Buffer.from(expected, 'base64url');
22
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'bad_signature' };
23
+ try { return { ok: true, payload: JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) }; }
24
+ catch { return { ok: false, reason: 'malformed' }; }
25
+ }
26
+
27
+ export function createEvents({ repositories, numbering, payment, audit, secret = 'dev-events-secret-change-me', now = () => new Date() } = {}) {
28
+ if (!repositories?.events || !repositories?.registrations || !repositories?.tickets) {
29
+ throw new Error('createEvents: repositories.{events,registrations,tickets} requis');
30
+ }
31
+ const { events, registrations, tickets } = repositories;
32
+ const evtNo = () => (numbering?.next ? numbering.next('event') : `EVT-${now().getFullYear()}-${Math.floor(now().getTime() % 100000)}`);
33
+ const tktNo = () => (numbering?.next ? numbering.next('ticket') : `TKT-${now().getFullYear()}-${Math.floor(now().getTime() % 1000000)}`);
34
+ const trace = (type, data) => audit?.log?.({ type, ...data, at: now() });
35
+
36
+ const api = {
37
+ eventStages: EVENT_STAGES, organizerTypes: ORG_TYPES, secret,
38
+
39
+ events: {
40
+ /** Crée un événement (brouillon). organizer = { type:'external'|'company'|'hadhinat', name, companyId? }. fee=0 → gratuit. */
41
+ async create({ title, organizer = {}, startsAt = null, endsAt = null, venue = null, capacity = 0, fee = 0, description = null } = {}) {
42
+ if (!title) throw new Error('title (intitulé) requis');
43
+ if (organizer.type && !ORG_TYPES.includes(organizer.type)) throw new Error(`organizer.type inconnu: ${organizer.type}`);
44
+ const e = await events.create({
45
+ no: evtNo(), title, organizerType: organizer.type || 'hadhinat', organizerName: organizer.name || 'Hadhinat',
46
+ organizerCompanyId: organizer.companyId || null, startsAt, endsAt, venue, capacity: Number(capacity) || 0,
47
+ fee: Number(fee) || 0, description, status: 'draft',
48
+ });
49
+ trace('event.created', { eventId: e.id });
50
+ return e;
51
+ },
52
+ get: (id) => events.findById(id),
53
+ list: (f = {}) => events.find((e) => Object.entries(f).every(([k, v]) => e[k] === v)),
54
+ async publish(id) { const e = await events.findById(id); if (!e) throw new Error('événement introuvable'); if (e.status !== 'draft') throw new Error(`publication impossible depuis '${e.status}'`); const r = await events.update(id, { status: 'published' }); trace('event.published', { eventId: id }); return r; },
55
+ async cancel(id) { const r = await events.update(id, { status: 'cancelled' }); trace('event.cancelled', { eventId: id }); return r; },
56
+ async close(id) { const r = await events.update(id, { status: 'closed' }); return r; },
57
+ /** Sièges : capacité, inscrits (hors annulés), restants. */
58
+ async seats(id) { const e = await events.findById(id); const taken = (await registrations.find((r) => r.eventId === id && r.status !== 'cancelled')).length; const capacity = e?.capacity || 0; return { capacity, taken, remaining: capacity ? Math.max(0, capacity - taken) : null }; },
59
+ },
60
+
61
+ registrations: {
62
+ /**
63
+ * Inscrit un participant à un événement publié. Émet un BILLET (token signé = charge utile du QR badge).
64
+ * Événement PAYANT (fee>0) → compose payment.charge si `payment` injecté ; sinon le billet reste 'pending-payment'.
65
+ */
66
+ async register(eventId, { name, email, phone = null } = {}) {
67
+ const e = await events.findById(eventId); if (!e) throw new Error('événement introuvable');
68
+ if (e.status !== 'published') throw new Error('inscriptions fermées (événement non publié)');
69
+ if (!email) throw new Error('email requis');
70
+ const s = await api.events.seats(eventId);
71
+ if (s.remaining === 0) throw new Error('complet : plus de place disponible');
72
+ const reg = await registrations.create({ eventId, name: name || email, email: String(email).toLowerCase(), phone, status: 'registered', at: now().toISOString() });
73
+ // Billet + jeton signé (badge / contrôle d'accès)
74
+ let paid = e.fee <= 0, paymentRef = null;
75
+ if (e.fee > 0 && payment?.charge) { const c = await payment.charge({ amount: e.fee, currency: 'DZD', purpose: 'event', eventId, registrationId: reg.id }); paid = !!c?.ok || !!c?.id; paymentRef = c?.id || c?.ref || null; }
76
+ const t = await tickets.create({ no: tktNo(), eventId, registrationId: reg.id, status: paid ? 'valid' : 'pending-payment', paymentRef });
77
+ const token = signTicket(secret, { t: t.id, e: eventId, r: reg.id });
78
+ await tickets.update(t.id, { token });
79
+ trace('event.registered', { eventId, registrationId: reg.id, ticketId: t.id, paid });
80
+ return { registration: reg, ticket: { ...t, token }, paid };
81
+ },
82
+ list: (eventId) => registrations.find((r) => r.eventId === eventId),
83
+ get: (id) => registrations.findById(id),
84
+ async cancel(id) { return registrations.update(id, { status: 'cancelled' }); },
85
+ },
86
+
87
+ tickets: {
88
+ get: (id) => tickets.findById(id),
89
+ byRegistration: async (regId) => (await tickets.find((t) => t.registrationId === regId))[0] || null,
90
+ /** Charge utile QR « badge » d'un billet (token signé). */
91
+ token: async (ticketId) => { const t = await tickets.findById(ticketId); return t?.token || null; },
92
+ },
93
+
94
+ checkin: {
95
+ /**
96
+ * Contrôle d'accès : valide un jeton QR scanné. Signature OK + billet valide + non déjà utilisé → marque 'used' (entrée).
97
+ * Renvoie { ok, reason?, event, registration, ticket }.
98
+ */
99
+ async validate(token) {
100
+ const v = verifyTicket(secret, token);
101
+ if (!v.ok) return { ok: false, reason: v.reason };
102
+ const t = await tickets.findById(v.payload.t);
103
+ if (!t) return { ok: false, reason: 'unknown_ticket' };
104
+ if (t.status === 'pending-payment') return { ok: false, reason: 'unpaid', ticket: t };
105
+ if (t.status === 'used') return { ok: false, reason: 'already_used', ticket: t };
106
+ if (t.status !== 'valid') return { ok: false, reason: 'invalid', ticket: t };
107
+ await tickets.update(t.id, { status: 'used', usedAt: now().toISOString() });
108
+ const reg = await registrations.findById(t.registrationId);
109
+ const event = await events.findById(t.eventId);
110
+ trace('event.checkin', { eventId: t.eventId, ticketId: t.id });
111
+ return { ok: true, event, registration: reg, ticket: { ...t, status: 'used' } };
112
+ },
113
+ /** Vérifie un jeton SANS le consommer (aperçu). */
114
+ peek: (token) => verifyTicket(secret, token),
115
+ },
116
+
117
+ // Helpers exposés pour l'app (génération des charges utiles QR).
118
+ sign: (payload) => signTicket(secret, payload),
119
+ verify: (token) => verifyTicket(secret, token),
120
+ };
121
+ return api;
122
+ }
123
+
124
+ // Repositories en mémoire (dev/démo) — DB réelle via @mostajs/orm en prod. Forme uniforme findById/find/create/update.
125
+ export function createMemoryRepositories() {
126
+ const mk = () => { let seq = 0; const rows = new Map(); return {
127
+ async create(o) { const id = 'ev' + (++seq) + '-' + Math.floor((seq * 2654435761) % 1e6); const r = { id, ...o }; rows.set(id, r); return { ...r }; },
128
+ async findById(id) { const r = rows.get(id); return r ? { ...r } : null; },
129
+ async find(pred) { return [...rows.values()].filter(pred).map((r) => ({ ...r })); },
130
+ async update(id, patch) { const r = rows.get(id); if (!r) return null; Object.assign(r, patch); return { ...r }; },
131
+ }; };
132
+ return { events: mk(), registrations: mk(), tickets: mk() };
133
+ }
134
+
135
+ export default { createEvents, createMemoryRepositories };
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // @mostajs/events — point d'entrée. Author: Dr Hamid MADANI <drmdh@msn.com>
2
+ // Domaine événements pur. Le RENDU QR (invitation/badge/contrôle d'accès) est délégué à @mostajs/qrpanel/qr-svg :
3
+ // l'app signe une charge utile via events.sign()/ticket.token puis l'encode avec qrSvg() — séparation domaine / présentation.
4
+ export { createEvents, createMemoryRepositories } from './events.js';
5
+ export { default } from './events.js';