@mostajs/ticketing 1.0.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.
Files changed (82) hide show
  1. package/README.md +479 -0
  2. package/dist/api/scan.route.d.ts +16 -0
  3. package/dist/api/scan.route.d.ts.map +1 -0
  4. package/dist/api/scan.route.js +75 -0
  5. package/dist/api/scan.route.js.map +1 -0
  6. package/dist/api/tickets.route.d.ts +15 -0
  7. package/dist/api/tickets.route.d.ts.map +1 -0
  8. package/dist/api/tickets.route.js +91 -0
  9. package/dist/api/tickets.route.js.map +1 -0
  10. package/dist/index.d.ts +11 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +14 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/lib/quota-manager.d.ts +18 -0
  15. package/dist/lib/quota-manager.d.ts.map +1 -0
  16. package/dist/lib/quota-manager.js +31 -0
  17. package/dist/lib/quota-manager.js.map +1 -0
  18. package/dist/lib/scan-processor.d.ts +39 -0
  19. package/dist/lib/scan-processor.d.ts.map +1 -0
  20. package/dist/lib/scan-processor.js +168 -0
  21. package/dist/lib/scan-processor.js.map +1 -0
  22. package/dist/lib/validity-checker.d.ts +19 -0
  23. package/dist/lib/validity-checker.d.ts.map +1 -0
  24. package/dist/lib/validity-checker.js +45 -0
  25. package/dist/lib/validity-checker.js.map +1 -0
  26. package/dist/repositories/activity.repository.d.ts +38 -0
  27. package/dist/repositories/activity.repository.d.ts.map +1 -0
  28. package/dist/repositories/activity.repository.js +23 -0
  29. package/dist/repositories/activity.repository.js.map +1 -0
  30. package/dist/repositories/client-access.repository.d.ts +31 -0
  31. package/dist/repositories/client-access.repository.d.ts.map +1 -0
  32. package/dist/repositories/client-access.repository.js +31 -0
  33. package/dist/repositories/client-access.repository.js.map +1 -0
  34. package/dist/repositories/index.d.ts +11 -0
  35. package/dist/repositories/index.d.ts.map +1 -0
  36. package/dist/repositories/index.js +8 -0
  37. package/dist/repositories/index.js.map +1 -0
  38. package/dist/repositories/scan-log.repository.d.ts +32 -0
  39. package/dist/repositories/scan-log.repository.d.ts.map +1 -0
  40. package/dist/repositories/scan-log.repository.js +48 -0
  41. package/dist/repositories/scan-log.repository.js.map +1 -0
  42. package/dist/repositories/subscription-plan.repository.d.ts +27 -0
  43. package/dist/repositories/subscription-plan.repository.d.ts.map +1 -0
  44. package/dist/repositories/subscription-plan.repository.js +19 -0
  45. package/dist/repositories/subscription-plan.repository.js.map +1 -0
  46. package/dist/repositories/ticket.repository.d.ts +48 -0
  47. package/dist/repositories/ticket.repository.d.ts.map +1 -0
  48. package/dist/repositories/ticket.repository.js +65 -0
  49. package/dist/repositories/ticket.repository.js.map +1 -0
  50. package/dist/schemas/activity.schema.d.ts +3 -0
  51. package/dist/schemas/activity.schema.d.ts.map +1 -0
  52. package/dist/schemas/activity.schema.js +39 -0
  53. package/dist/schemas/activity.schema.js.map +1 -0
  54. package/dist/schemas/client-access.schema.d.ts +3 -0
  55. package/dist/schemas/client-access.schema.d.ts.map +1 -0
  56. package/dist/schemas/client-access.schema.js +25 -0
  57. package/dist/schemas/client-access.schema.js.map +1 -0
  58. package/dist/schemas/counter.schema.d.ts +3 -0
  59. package/dist/schemas/counter.schema.d.ts.map +1 -0
  60. package/dist/schemas/counter.schema.js +11 -0
  61. package/dist/schemas/counter.schema.js.map +1 -0
  62. package/dist/schemas/index.d.ts +7 -0
  63. package/dist/schemas/index.d.ts.map +1 -0
  64. package/dist/schemas/index.js +9 -0
  65. package/dist/schemas/index.js.map +1 -0
  66. package/dist/schemas/scan-log.schema.d.ts +3 -0
  67. package/dist/schemas/scan-log.schema.d.ts.map +1 -0
  68. package/dist/schemas/scan-log.schema.js +26 -0
  69. package/dist/schemas/scan-log.schema.js.map +1 -0
  70. package/dist/schemas/subscription-plan.schema.d.ts +3 -0
  71. package/dist/schemas/subscription-plan.schema.d.ts.map +1 -0
  72. package/dist/schemas/subscription-plan.schema.js +29 -0
  73. package/dist/schemas/subscription-plan.schema.js.map +1 -0
  74. package/dist/schemas/ticket.schema.d.ts +3 -0
  75. package/dist/schemas/ticket.schema.d.ts.map +1 -0
  76. package/dist/schemas/ticket.schema.js +35 -0
  77. package/dist/schemas/ticket.schema.js.map +1 -0
  78. package/dist/types/index.d.ts +99 -0
  79. package/dist/types/index.d.ts.map +1 -0
  80. package/dist/types/index.js +4 -0
  81. package/dist/types/index.js.map +1 -0
  82. package/package.json +84 -0
package/README.md ADDED
@@ -0,0 +1,479 @@
1
+ # @mostajs/ticketing
2
+
3
+ > Reusable ticketing module — ticket lifecycle, scan validation, quota management, API route factories.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@mostajs/ticketing.svg)](https://www.npmjs.com/package/@mostajs/ticketing)
6
+ [![license](https://img.shields.io/npm/l/@mostajs/ticketing.svg)](LICENSE)
7
+
8
+ Part of the [@mosta suite](https://mostajs.dev). Depends only on `@mostajs/orm`. **Zero coupling** with auth, RBAC, or any framework beyond standard `Request`/`Response`.
9
+
10
+ ---
11
+
12
+ ## Table des matieres
13
+
14
+ 1. [Installation](#installation)
15
+ 2. [Concepts cles](#concepts-cles)
16
+ 3. [Quick Start (5 etapes)](#quick-start)
17
+ 4. [Formats de codes supportes](#formats-de-codes)
18
+ 5. [API Route Factories](#api-route-factories)
19
+ 6. [Core Logic (fonctions pures)](#core-logic)
20
+ 7. [Schemas & Repositories](#schemas--repositories)
21
+ 8. [Integration complete dans une nouvelle app](#integration-complete)
22
+ 9. [API Reference](#api-reference)
23
+ 10. [Architecture](#architecture)
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install @mostajs/ticketing
31
+ ```
32
+
33
+ Prerequis : `@mostajs/orm` doit etre configure avec votre base de donnees.
34
+
35
+ ---
36
+
37
+ ## Concepts cles
38
+
39
+ ### Cycle de vie d'un ticket
40
+
41
+ ```
42
+ [create] → active → [scan] → used → (expired)
43
+ ↑ ↓
44
+ └── day_reentry (re-scan meme jour)
45
+ ```
46
+
47
+ ### Modes de validite
48
+
49
+ | Mode | Comportement |
50
+ |------|-------------|
51
+ | `single_use` | Un seul scan, puis `used` |
52
+ | `day_reentry` | Re-entree illimitee le meme jour, quota decremente une seule fois |
53
+ | `time_slot` | Valide pendant N minutes apres creation |
54
+ | `unlimited` | Pas d'expiration |
55
+
56
+ ### Quota
57
+
58
+ - `totalQuota` : nombre total de tickets autorise pour un acces
59
+ - `remainingQuota` : decremente a chaque scan (sauf re-entrees day_reentry)
60
+ - Status `depleted` automatique quand quota atteint 0
61
+
62
+ ### Formats de codes
63
+
64
+ Le champ `codeFormat` permet de generer et scanner differents types de codes :
65
+
66
+ | Format | Type | Usage typique |
67
+ |--------|------|--------------|
68
+ | `qrcode` | 2D | Le plus courant, haute capacite |
69
+ | `code128` | 1D | Alphanumerique, logistique |
70
+ | `code39` | 1D | Alphanumerique, industrie |
71
+ | `ean13` | 1D | 13 chiffres, retail Europe |
72
+ | `ean8` | 1D | 8 chiffres, petits produits |
73
+ | `upc_a` | 1D | 12 chiffres, retail USA |
74
+ | `itf` | 1D | Paires numeriques, colis |
75
+ | `pdf417` | 2D | Haute capacite, cartes d'identite |
76
+ | `datamatrix` | 2D | Petit format, composants |
77
+ | `aztec` | 2D | Compact, cartes d'embarquement |
78
+
79
+ ---
80
+
81
+ ## Quick Start
82
+
83
+ ### Etape 1 — Enregistrer les schemas
84
+
85
+ ```typescript
86
+ // src/dal/registry.ts
87
+ import { registerSchema } from '@mostajs/orm'
88
+ import {
89
+ TicketSchema,
90
+ ClientAccessSchema,
91
+ ScanLogSchema,
92
+ ActivitySchema,
93
+ SubscriptionPlanSchema,
94
+ CounterSchema,
95
+ } from '@mostajs/ticketing'
96
+
97
+ registerSchema(TicketSchema)
98
+ registerSchema(ClientAccessSchema)
99
+ registerSchema(ScanLogSchema)
100
+ registerSchema(ActivitySchema)
101
+ registerSchema(SubscriptionPlanSchema)
102
+ registerSchema(CounterSchema)
103
+ ```
104
+
105
+ ### Etape 2 — Instancier les repositories
106
+
107
+ ```typescript
108
+ // src/dal/service.ts
109
+ import { getDialect } from '@mostajs/orm'
110
+ import {
111
+ TicketRepository,
112
+ ClientAccessRepository,
113
+ ScanLogRepository,
114
+ ActivityRepository,
115
+ SubscriptionPlanRepository,
116
+ } from '@mostajs/ticketing'
117
+
118
+ export async function ticketRepo() {
119
+ return new TicketRepository(await getDialect())
120
+ }
121
+ export async function clientAccessRepo() {
122
+ return new ClientAccessRepository(await getDialect())
123
+ }
124
+ export async function scanLogRepo() {
125
+ return new ScanLogRepository(await getDialect())
126
+ }
127
+ export async function activityRepo() {
128
+ return new ActivityRepository(await getDialect())
129
+ }
130
+ export async function planRepo() {
131
+ return new SubscriptionPlanRepository(await getDialect())
132
+ }
133
+ ```
134
+
135
+ ### Etape 3 — Route scan (POST /api/scan)
136
+
137
+ ```typescript
138
+ // src/app/api/scan/route.ts
139
+ import { createScanHandler } from '@mostajs/ticketing/api/scan.route'
140
+ import { ticketRepo, clientAccessRepo, scanLogRepo, clientRepo } from '@/dal/service'
141
+ import { checkPermission } from '@/lib/authCheck'
142
+
143
+ export const { POST } = createScanHandler({
144
+ checkAuth: async () => {
145
+ const { error, session } = await checkPermission('scan:validate')
146
+ return { error: error || null, userId: session?.user?.id || '' }
147
+ },
148
+
149
+ getRepositories: async () => ({
150
+ ticketRepo: await ticketRepo(),
151
+ clientAccessRepo: await clientAccessRepo(),
152
+ scanLogRepo: await scanLogRepo(),
153
+ clientRepo: await clientRepo(),
154
+ }),
155
+
156
+ // Optionnel : audit apres scan reussi
157
+ onGranted: async ({ ticket, client, isReentry, userId }) => {
158
+ console.log(`Scan ${isReentry ? 'reentry' : 'granted'}: ${ticket.ticketNumber}`)
159
+ },
160
+ })
161
+ ```
162
+
163
+ ### Etape 4 — Route tickets (GET + POST /api/tickets)
164
+
165
+ ```typescript
166
+ // src/app/api/tickets/route.ts
167
+ import { createTicketsHandler } from '@mostajs/ticketing/api/tickets.route'
168
+ import { ticketRepo, clientRepo, clientAccessRepo, activityRepo } from '@/dal/service'
169
+ import { checkPermission } from '@/lib/authCheck'
170
+
171
+ export const { GET, POST } = createTicketsHandler({
172
+ checkAuth: async (req, permission) => {
173
+ const { error, session } = await checkPermission(permission)
174
+ return { error: error || null, userId: session?.user?.id || '' }
175
+ },
176
+
177
+ getRepositories: async () => ({
178
+ ticketRepo: await ticketRepo(),
179
+ clientRepo: await clientRepo(),
180
+ clientAccessRepo: await clientAccessRepo(),
181
+ activityRepo: await activityRepo(),
182
+ }),
183
+
184
+ // Format de code par defaut pour les nouveaux tickets
185
+ defaultCodeFormat: 'qrcode',
186
+
187
+ // Optionnel : callback apres creation
188
+ onCreated: async ({ ticket, userId }) => {
189
+ console.log(`Ticket ${ticket.ticketNumber} cree`)
190
+ },
191
+ })
192
+ ```
193
+
194
+ ### Etape 5 — Tester
195
+
196
+ ```bash
197
+ # Creer un ticket
198
+ curl -X POST http://localhost:3000/api/tickets \
199
+ -H 'Content-Type: application/json' \
200
+ -d '{"clientId": "abc", "activityId": "xyz"}'
201
+
202
+ # Scanner un ticket
203
+ curl -X POST http://localhost:3000/api/scan \
204
+ -H 'Content-Type: application/json' \
205
+ -d '{"code": "uuid-du-ticket"}'
206
+
207
+ # Avec un code-barres specifique
208
+ curl -X POST http://localhost:3000/api/tickets \
209
+ -H 'Content-Type: application/json' \
210
+ -d '{"clientId": "abc", "activityId": "xyz", "codeFormat": "code128"}'
211
+ ```
212
+
213
+ ---
214
+
215
+ ## API Route Factories
216
+
217
+ ### createScanHandler(config)
218
+
219
+ | Option | Type | Description |
220
+ |--------|------|-------------|
221
+ | `getRepositories` | `() => Promise<{...}>` | Fournit ticketRepo, clientAccessRepo, scanLogRepo, clientRepo |
222
+ | `checkAuth` | `(req) => Promise<{error, userId}>` | Verifie auth + permissions |
223
+ | `onGranted?` | `(data) => Promise<void>` | Callback apres scan reussi (audit, notifications) |
224
+ | `onDenied?` | `(data) => Promise<void>` | Callback apres scan refuse (alertes) |
225
+
226
+ **Requete :**
227
+ ```json
228
+ POST /api/scan
229
+ { "code": "uuid-or-barcode-value", "scanMethod": "webcam" }
230
+ ```
231
+
232
+ **Reponse (granted) :**
233
+ ```json
234
+ {
235
+ "data": {
236
+ "result": "granted",
237
+ "isReentry": false,
238
+ "ticket": { "ticketNumber": "TKT-20260306-0001", "clientName": "Alice Dupont", ... },
239
+ "client": { "name": "Alice Dupont", "photo": "/photos/alice.jpg" },
240
+ "access": { "remainingQuota": 9, "totalQuota": 10, "status": "active" }
241
+ }
242
+ }
243
+ ```
244
+
245
+ **Reponse (denied) :**
246
+ ```json
247
+ {
248
+ "data": {
249
+ "result": "denied",
250
+ "reason": "ticket_already_used",
251
+ "ticket": { "ticketNumber": "TKT-20260306-0001", ... }
252
+ }
253
+ }
254
+ ```
255
+
256
+ ### createTicketsHandler(config)
257
+
258
+ | Option | Type | Description |
259
+ |--------|------|-------------|
260
+ | `getRepositories` | `() => Promise<{...}>` | Fournit ticketRepo, clientRepo, clientAccessRepo, activityRepo |
261
+ | `checkAuth` | `(req, permission) => Promise<{error, userId}>` | Auth avec nom de permission (`ticket:view`, `ticket:create`) |
262
+ | `onCreated?` | `(data) => Promise<void>` | Callback apres creation ticket |
263
+ | `defaultCodeFormat?` | `CodeFormat` | Format de code par defaut (defaut: `'qrcode'`) |
264
+
265
+ ---
266
+
267
+ ## Core Logic
268
+
269
+ Fonctions pures, utilisables partout (serveur, worker, CLI, tests) :
270
+
271
+ ### processScan(code, scanMethod, scannedBy, deps)
272
+
273
+ Pipeline 8 etapes de validation. Toute l'I/O est injectee via `deps`.
274
+
275
+ ```typescript
276
+ import { processScan } from '@mostajs/ticketing'
277
+ import type { ScanDeps } from '@mostajs/ticketing'
278
+
279
+ const deps: ScanDeps = {
280
+ findTicketByCode: async (code) => db.tickets.findOne({ code }),
281
+ findAccessById: async (id) => db.accesses.findById(id),
282
+ findClientById: async (id) => db.clients.findById(id),
283
+ wasScannedToday: async (ticketId) => { /* ... */ },
284
+ updateTicket: async (id, data) => db.tickets.update(id, data),
285
+ updateAccess: async (id, data) => db.accesses.update(id, data),
286
+ createScanLog: async (data) => db.scanLogs.create(data),
287
+ resolveId: (ref) => typeof ref === 'string' ? ref : ref.id,
288
+ formatClientName: (c) => `${c.firstName} ${c.lastName}`,
289
+ }
290
+
291
+ const result = await processScan('ticket-uuid', 'webcam', 'user-123', deps)
292
+ ```
293
+
294
+ ### computeValidUntil(mode, durationMinutes)
295
+
296
+ ```typescript
297
+ import { computeValidUntil } from '@mostajs/ticketing'
298
+
299
+ computeValidUntil('day_reentry', null) // → fin de journee (23:59:59)
300
+ computeValidUntil('time_slot', 90) // → now + 90 minutes
301
+ computeValidUntil('single_use', null) // → null
302
+ ```
303
+
304
+ ### decrementQuota(remainingQuota)
305
+
306
+ ```typescript
307
+ import { decrementQuota } from '@mostajs/ticketing'
308
+
309
+ decrementQuota(5) // → { remainingQuota: 4 }
310
+ decrementQuota(1) // → { remainingQuota: 0, status: 'depleted' }
311
+ decrementQuota(null) // → null (unlimited)
312
+ ```
313
+
314
+ ---
315
+
316
+ ## Schemas & Repositories
317
+
318
+ ### Schemas disponibles
319
+
320
+ | Schema | Collection | Description |
321
+ |--------|-----------|-------------|
322
+ | `TicketSchema` | `tickets` | Ticket avec code (QR/barcode), validite, statut |
323
+ | `ClientAccessSchema` | `client_accesses` | Acces client-activite avec quota |
324
+ | `ScanLogSchema` | `scan_logs` | Journal des scans (granted/denied) |
325
+ | `ActivitySchema` | `activities` | Activites avec planning et mode de validite |
326
+ | `SubscriptionPlanSchema` | `subscription_plans` | Plans d'abonnement |
327
+ | `CounterSchema` | `counters` | Auto-increment interne |
328
+
329
+ ### Repositories
330
+
331
+ | Repository | Methodes cles |
332
+ |-----------|--------------|
333
+ | `TicketRepository` | `createWithAutoFields()`, `findByCode()`, `markUsed()`, `countByAccess()` |
334
+ | `ClientAccessRepository` | `findActiveAccess()`, `decrementQuota()`, `block()` |
335
+ | `ScanLogRepository` | `wasScannedToday()`, `countToday()`, `findDistinctClientsToday()` |
336
+ | `ActivityRepository` | `findActive()`, `findBySlug()`, `findAllOrdered()` |
337
+ | `SubscriptionPlanRepository` | `findActive()`, `findAllWithActivities()` |
338
+
339
+ ---
340
+
341
+ ## Integration complete
342
+
343
+ ### Nouvelle app Next.js depuis zero
344
+
345
+ ```bash
346
+ # 1. Creer le projet
347
+ npx create-next-app@latest my-ticketing-app
348
+ cd my-ticketing-app
349
+
350
+ # 2. Installer
351
+ npm install @mostajs/orm @mostajs/ticketing
352
+
353
+ # 3. Configurer la DB
354
+ echo 'DATABASE_URL=mongodb://localhost:27017/myapp' >> .env.local
355
+
356
+ # 4. Enregistrer les schemas dans src/dal/registry.ts
357
+ # 5. Creer les repo helpers dans src/dal/service.ts
358
+ # 6. Creer les routes API (2 fichiers, ~30 lignes chacun)
359
+ # 7. npm run dev
360
+ ```
361
+
362
+ ### Avec audit (@mostajs/audit)
363
+
364
+ ```typescript
365
+ import { createScanHandler } from '@mostajs/ticketing/api/scan.route'
366
+ import { logAudit, getAuditUser } from '@mostajs/audit/lib/audit'
367
+
368
+ export const { POST } = createScanHandler({
369
+ // ...
370
+ onGranted: async ({ ticket, client, isReentry, userId }) => {
371
+ await logAudit({
372
+ userId,
373
+ action: isReentry ? 'scan_reentry' : 'scan_granted',
374
+ module: 'scan',
375
+ resource: ticket.ticketNumber,
376
+ })
377
+ },
378
+ onDenied: async ({ reason, ticket, userId }) => {
379
+ await logAudit({
380
+ userId,
381
+ action: 'scan_denied',
382
+ module: 'scan',
383
+ resource: ticket?.ticketNumber,
384
+ details: { reason },
385
+ })
386
+ },
387
+ })
388
+ ```
389
+
390
+ ### Avec un scanner de code-barres physique
391
+
392
+ ```typescript
393
+ // Le scanner physique envoie le meme POST /api/scan
394
+ // Seul le scanMethod change
395
+ fetch('/api/scan', {
396
+ method: 'POST',
397
+ body: JSON.stringify({
398
+ code: 'TKT-20260306-0001', // valeur lue par le scanner
399
+ scanMethod: 'handheld_scanner',
400
+ }),
401
+ })
402
+ ```
403
+
404
+ ---
405
+
406
+ ## API Reference
407
+
408
+ ### Types
409
+
410
+ | Type | Description |
411
+ |------|-------------|
412
+ | `CodeFormat` | `'qrcode' \| 'code128' \| 'code39' \| 'ean13' \| 'ean8' \| 'upc_a' \| 'itf' \| 'pdf417' \| 'datamatrix' \| 'aztec'` |
413
+ | `ValidityMode` | `'day_reentry' \| 'single_use' \| 'time_slot' \| 'unlimited'` |
414
+ | `TicketStatus` | `'active' \| 'used' \| 'expired' \| 'cancelled'` |
415
+ | `ScanResult` | `'granted' \| 'denied'` |
416
+ | `ScanMethod` | `'webcam' \| 'pwa_camera' \| 'handheld_scanner' \| 'nfc'` |
417
+ | `AccessType` | `'unlimited' \| 'count' \| 'temporal' \| 'mixed'` |
418
+ | `DenyReason` | `'invalid_ticket' \| 'ticket_already_used' \| 'ticket_expired' \| 'ticket_cancelled' \| 'quota_depleted' \| 'access_expired' \| 'client_suspended'` |
419
+ | `ScanDeps` | Interface d'injection pour `processScan()` |
420
+ | `ScanHandlerConfig` | Config de `createScanHandler()` |
421
+ | `TicketsHandlerConfig` | Config de `createTicketsHandler()` |
422
+
423
+ ---
424
+
425
+ ## Architecture
426
+
427
+ ```
428
+ @mostajs/ticketing
429
+ ├── schemas/
430
+ │ ├── ticket.schema.ts # Ticket (code multi-format, validite, statut)
431
+ │ ├── client-access.schema.ts # Acces client-activite avec quota
432
+ │ ├── scan-log.schema.ts # Journal des scans
433
+ │ ├── activity.schema.ts # Activites (planning, mode validite)
434
+ │ ├── subscription-plan.schema.ts # Plans d'abonnement
435
+ │ └── counter.schema.ts # Auto-increment sequences
436
+ ├── repositories/
437
+ │ ├── ticket.repository.ts # CRUD + findByCode, createWithAutoFields
438
+ │ ├── client-access.repository.ts # Quota, findActiveAccess
439
+ │ ├── scan-log.repository.ts # wasScannedToday, countToday
440
+ │ ├── activity.repository.ts # findActive, findBySlug
441
+ │ └── subscription-plan.repository.ts
442
+ ├── lib/
443
+ │ ├── scan-processor.ts # Pipeline 8 etapes (pure, injectable)
444
+ │ ├── validity-checker.ts # computeValidUntil, isExpired
445
+ │ └── quota-manager.ts # decrementQuota, wouldExceedQuota
446
+ ├── api/
447
+ │ ├── scan.route.ts # Factory createScanHandler(config)
448
+ │ └── tickets.route.ts # Factory createTicketsHandler(config)
449
+ ├── types/
450
+ │ └── index.ts # CodeFormat, ValidityMode, ScanDeps, etc.
451
+ └── index.ts # Barrel exports
452
+
453
+ Dependances:
454
+ @mostajs/orm (seule dep runtime)
455
+ next >= 14 (peer, optionnel)
456
+
457
+ Zero dependance sur: @mostajs/auth, @mostajs/rbac, @mostajs/audit
458
+ (l'app injecte ses propres callbacks auth/audit)
459
+ ```
460
+
461
+ ### Pattern Factory (injection de dependances)
462
+
463
+ ```
464
+ ┌──────────────────────┐ inject callbacks ┌──────────────────────┐
465
+ │ @mostajs/ticketing │ ◄──────────────────────── │ Votre app │
466
+ │ │ │ │
467
+ │ createScanHandler({ │ │ checkAuth: () => │
468
+ │ checkAuth, │ │ verifyToken(...) │
469
+ │ getRepositories, │ │ getRepositories: () │
470
+ │ onGranted, │ │ => { ticketRepo, │
471
+ │ }) │ │ scanLogRepo } │
472
+ └──────────────────────┘ └──────────────────────┘
473
+ ```
474
+
475
+ ---
476
+
477
+ ## License
478
+
479
+ MIT — Dr Hamid MADANI <drmdh@msn.com>
@@ -0,0 +1,16 @@
1
+ import type { ScanHandlerConfig } from '../types/index';
2
+ /**
3
+ * Factory: creates a POST handler for ticket scanning.
4
+ *
5
+ * The host app injects its repositories, auth check, and optional audit callbacks.
6
+ *
7
+ * ```ts
8
+ * // src/app/api/scan/route.ts
9
+ * import { createScanHandler } from '@mostajs/ticketing/api/scan.route'
10
+ * export const { POST } = createScanHandler({ ... })
11
+ * ```
12
+ */
13
+ export declare function createScanHandler(config: ScanHandlerConfig): {
14
+ POST: (req: Request) => Promise<Response>;
15
+ };
16
+ //# sourceMappingURL=scan.route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan.route.d.ts","sourceRoot":"","sources":["../../api/scan.route.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAc,MAAM,gBAAgB,CAAC;AAEpE;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,iBAAiB;gBAChC,OAAO;EAmEjC"}
@@ -0,0 +1,75 @@
1
+ // @mostajs/ticketing — Scan route factory
2
+ // Author: Dr Hamid MADANI drmdh@msn.com
3
+ import { processScan } from '../lib/scan-processor';
4
+ /**
5
+ * Factory: creates a POST handler for ticket scanning.
6
+ *
7
+ * The host app injects its repositories, auth check, and optional audit callbacks.
8
+ *
9
+ * ```ts
10
+ * // src/app/api/scan/route.ts
11
+ * import { createScanHandler } from '@mostajs/ticketing/api/scan.route'
12
+ * export const { POST } = createScanHandler({ ... })
13
+ * ```
14
+ */
15
+ export function createScanHandler(config) {
16
+ async function POST(req) {
17
+ const { error, userId } = await config.checkAuth(req);
18
+ if (error)
19
+ return error;
20
+ const body = await req.json();
21
+ const { code, qrCode, scanMethod = 'webcam' } = body;
22
+ // Support both "code" (generic) and "qrCode" (legacy) field names
23
+ const codeValue = code || qrCode;
24
+ if (!codeValue) {
25
+ return Response.json({ error: { code: 'INVALID', message: 'Code manquant' } }, { status: 400 });
26
+ }
27
+ const repos = await config.getRepositories();
28
+ const resolveId = (ref) => {
29
+ if (!ref)
30
+ return '';
31
+ if (typeof ref === 'string')
32
+ return ref;
33
+ return ref.id ?? ref._id ?? String(ref);
34
+ };
35
+ const result = await processScan(codeValue, scanMethod, userId, {
36
+ findTicketByCode: (c) => repos.ticketRepo.findByCode?.(c) ?? repos.ticketRepo.findOne({ code: c }),
37
+ findAccessById: (id) => repos.clientAccessRepo.findById(id),
38
+ findClientById: (id) => repos.clientRepo.findById(id),
39
+ wasScannedToday: async (ticketId) => {
40
+ const start = new Date();
41
+ start.setHours(0, 0, 0, 0);
42
+ const log = await repos.scanLogRepo.findOne({
43
+ ticket: ticketId,
44
+ result: 'granted',
45
+ timestamp: { $gte: start },
46
+ });
47
+ return log !== null;
48
+ },
49
+ updateTicket: (id, data) => repos.ticketRepo.update(id, data),
50
+ updateAccess: (id, data) => repos.clientAccessRepo.update(id, data),
51
+ createScanLog: (data) => repos.scanLogRepo.create(data),
52
+ resolveId,
53
+ formatClientName: (client) => `${client.firstName} ${client.lastName}`,
54
+ });
55
+ if (result.result === 'granted' && config.onGranted) {
56
+ await config.onGranted({
57
+ ticket: result.ticket,
58
+ client: result.client,
59
+ access: result.access,
60
+ isReentry: result.isReentry,
61
+ userId,
62
+ });
63
+ }
64
+ if (result.result === 'denied' && config.onDenied) {
65
+ await config.onDenied({
66
+ reason: result.reason,
67
+ ticket: result.ticket,
68
+ userId,
69
+ });
70
+ }
71
+ return Response.json({ data: result });
72
+ }
73
+ return { POST };
74
+ }
75
+ //# sourceMappingURL=scan.route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scan.route.js","sourceRoot":"","sources":["../../api/scan.route.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import type { TicketsHandlerConfig } from '../types/index';
2
+ /**
3
+ * Factory: creates GET + POST handlers for ticket management.
4
+ *
5
+ * ```ts
6
+ * // src/app/api/tickets/route.ts
7
+ * import { createTicketsHandler } from '@mostajs/ticketing/api/tickets.route'
8
+ * export const { GET, POST } = createTicketsHandler({ ... })
9
+ * ```
10
+ */
11
+ export declare function createTicketsHandler(config: TicketsHandlerConfig): {
12
+ GET: (req: Request) => Promise<Response>;
13
+ POST: (req: Request) => Promise<Response>;
14
+ };
15
+ //# sourceMappingURL=tickets.route.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tickets.route.d.ts","sourceRoot":"","sources":["../../api/tickets.route.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,oBAAoB,EAAc,MAAM,gBAAgB,CAAC;AAEvE;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB;eAEvC,OAAO;gBAmBN,OAAO;EA+EjC"}
@@ -0,0 +1,91 @@
1
+ // @mostajs/ticketing — Tickets route factory (GET list + POST create)
2
+ // Author: Dr Hamid MADANI drmdh@msn.com
3
+ import { computeValidUntil } from '../lib/validity-checker';
4
+ import { wouldExceedQuota } from '../lib/quota-manager';
5
+ /**
6
+ * Factory: creates GET + POST handlers for ticket management.
7
+ *
8
+ * ```ts
9
+ * // src/app/api/tickets/route.ts
10
+ * import { createTicketsHandler } from '@mostajs/ticketing/api/tickets.route'
11
+ * export const { GET, POST } = createTicketsHandler({ ... })
12
+ * ```
13
+ */
14
+ export function createTicketsHandler(config) {
15
+ async function GET(req) {
16
+ const { error } = await config.checkAuth(req, 'ticket:view');
17
+ if (error)
18
+ return error;
19
+ const url = new URL(req.url);
20
+ const clientId = url.searchParams.get('clientId');
21
+ const status = url.searchParams.get('status');
22
+ const limit = parseInt(url.searchParams.get('limit') || '50');
23
+ const filter = {};
24
+ if (clientId)
25
+ filter.client = clientId;
26
+ if (status)
27
+ filter.status = status;
28
+ const repos = await config.getRepositories();
29
+ const tickets = await repos.ticketRepo.findAll(filter, { sort: { createdAt: -1 }, limit });
30
+ return Response.json({ data: tickets });
31
+ }
32
+ async function POST(req) {
33
+ const { error, userId } = await config.checkAuth(req, 'ticket:create');
34
+ if (error)
35
+ return error;
36
+ const body = await req.json();
37
+ const { clientId, activityId, ticketType = 'standard', sourceClientId, amount = 0, codeFormat } = body;
38
+ if (!clientId || !activityId) {
39
+ return Response.json({ error: { code: 'VALIDATION_ERROR', message: 'clientId et activityId requis' } }, { status: 400 });
40
+ }
41
+ // For gift tickets, the access check is on the sourceClient
42
+ const accessClientId = ticketType === 'cadeau' && sourceClientId ? sourceClientId : clientId;
43
+ const repos = await config.getRepositories();
44
+ const [client, activity, clientAccess] = await Promise.all([
45
+ repos.clientRepo.findById(clientId),
46
+ repos.activityRepo.findById(activityId),
47
+ repos.clientAccessRepo.findActiveAccess(accessClientId, activityId),
48
+ ]);
49
+ if (!client) {
50
+ return Response.json({ error: { code: 'NOT_FOUND', message: 'Client non trouvé' } }, { status: 404 });
51
+ }
52
+ if (!activity) {
53
+ return Response.json({ error: { code: 'NOT_FOUND', message: 'Activité non trouvée' } }, { status: 404 });
54
+ }
55
+ if (!clientAccess) {
56
+ return Response.json({ error: { code: 'NO_ACCESS', message: "Ce client n'a pas accès à cette activité" } }, { status: 403 });
57
+ }
58
+ // Check quota
59
+ if (clientAccess.totalQuota != null) {
60
+ const ticketCount = await repos.ticketRepo.countByAccess(clientAccess.id);
61
+ if (wouldExceedQuota(clientAccess.totalQuota, ticketCount)) {
62
+ return Response.json({ error: { code: 'QUOTA_EXCEEDED', message: `Quota atteint : ${ticketCount}/${clientAccess.totalQuota} tickets` } }, { status: 403 });
63
+ }
64
+ }
65
+ // Gift tickets don't expire (usable another day)
66
+ const validUntil = ticketType === 'cadeau'
67
+ ? null
68
+ : computeValidUntil(activity.ticketValidityMode, activity.ticketDuration);
69
+ const resolvedFormat = codeFormat || config.defaultCodeFormat || 'qrcode';
70
+ const ticket = await repos.ticketRepo.createWithAutoFields({
71
+ client: clientId,
72
+ clientAccess: clientAccess.id,
73
+ activity: activityId,
74
+ ticketType,
75
+ sourceClient: ticketType === 'cadeau' ? sourceClientId : null,
76
+ clientName: `${client.firstName} ${client.lastName}`,
77
+ activityName: activity.name,
78
+ validityMode: activity.ticketValidityMode,
79
+ validUntil: validUntil?.toISOString() || null,
80
+ amount,
81
+ codeFormat: resolvedFormat,
82
+ createdBy: userId,
83
+ });
84
+ if (config.onCreated) {
85
+ await config.onCreated({ ticket, userId });
86
+ }
87
+ return Response.json({ data: ticket }, { status: 201 });
88
+ }
89
+ return { GET, POST };
90
+ }
91
+ //# sourceMappingURL=tickets.route.js.map