create-mercato-app 0.6.4-develop.4264.1.53368d85fe → 0.6.4-develop.4282.1.4d95e85930

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.
@@ -63,14 +63,8 @@ src/modules/<module_id>/
63
63
  │ ├── entities.ts # MikroORM entity classes
64
64
  │ └── validators.ts # Zod validation schemas
65
65
  ├── api/
66
- ├── get/
67
- └── <entities>.ts # GET /api/<module>/<entities> (list + detail)
68
- │ ├── post/
69
- │ │ └── <entities>.ts # POST /api/<module>/<entities>
70
- │ ├── put/
71
- │ │ └── <entities>.ts # PUT /api/<module>/<entities>
72
- │ └── delete/
73
- │ └── <entities>.ts # DELETE /api/<module>/<entities>
66
+ └── <entities>/
67
+ └── route.ts # All HTTP methods in one file: GET, POST, PUT, DELETE
74
68
  └── backend/
75
69
  ├── page.tsx # List page → /backend/<module>
76
70
  ├── <entities>/
@@ -150,6 +144,11 @@ export class <Entity> {
150
144
  ```typescript
151
145
  import { z } from 'zod'
152
146
 
147
+ export const list<Entity>Schema = z.object({
148
+ search: z.string().optional(),
149
+ id: z.string().uuid().optional(),
150
+ })
151
+
153
152
  export const create<Entity>Schema = z.object({
154
153
  name: z.string().min(1).max(255),
155
154
  // Add domain fields matching entity
@@ -159,6 +158,7 @@ export const update<Entity>Schema = create<Entity>Schema.partial().extend({
159
158
  id: z.string().uuid(),
160
159
  })
161
160
 
161
+ export type List<Entity>Query = z.infer<typeof list<Entity>Schema>
162
162
  export type Create<Entity>Input = z.infer<typeof create<Entity>Schema>
163
163
  export type Update<Entity>Input = z.infer<typeof update<Entity>Schema>
164
164
  ```
@@ -173,107 +173,60 @@ export type Update<Entity>Input = z.infer<typeof update<Entity>Schema>
173
173
 
174
174
  ## 5. Create API Routes
175
175
 
176
- Use `makeCrudRoute` for standard CRUD. Each HTTP method lives in its own file.
177
-
178
- ### GET Route
179
-
180
- **File**: `src/modules/<module_id>/api/get/<entities>.ts`
181
-
182
- ```typescript
183
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
184
- import { <Entity> } from '../../data/entities'
185
-
186
- const handler = makeCrudRoute({
187
- entity: <Entity>,
188
- entityId: '<module_id>.<entity>',
189
- operations: ['list', 'detail'],
190
- indexer: { entityType: '<module_id>.<entity>' },
191
- })
192
-
193
- export default handler
194
-
195
- export const openApi = {
196
- summary: 'List and retrieve <entities>',
197
- tags: ['<Module Name>'],
198
- }
199
- ```
200
-
201
- ### POST Route
202
-
203
- **File**: `src/modules/<module_id>/api/post/<entities>.ts`
204
-
205
- ```typescript
206
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
207
- import { <Entity> } from '../../data/entities'
208
- import { create<Entity>Schema } from '../../data/validators'
209
-
210
- const handler = makeCrudRoute({
211
- entity: <Entity>,
212
- entityId: '<module_id>.<entity>',
213
- operations: ['create'],
214
- schema: create<Entity>Schema,
215
- })
216
-
217
- export default handler
218
-
219
- export const openApi = {
220
- summary: 'Create a <entity>',
221
- tags: ['<Module Name>'],
222
- }
223
- ```
224
-
225
- ### PUT Route
176
+ Use `makeCrudRoute` for standard CRUD. All HTTP methods live in a single `route.ts` file.
226
177
 
227
- **File**: `src/modules/<module_id>/api/put/<entities>.ts`
178
+ **File**: `src/modules/<module_id>/api/<entities>/route.ts`
228
179
 
229
180
  ```typescript
230
181
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
231
182
  import { <Entity> } from '../../data/entities'
232
- import { update<Entity>Schema } from '../../data/validators'
183
+ import {
184
+ list<Entity>Schema,
185
+ create<Entity>Schema,
186
+ update<Entity>Schema,
187
+ } from '../../data/validators'
233
188
 
234
- const handler = makeCrudRoute({
235
- entity: <Entity>,
236
- entityId: '<module_id>.<entity>',
237
- operations: ['update'],
238
- schema: update<Entity>Schema,
239
- })
240
-
241
- export default handler
242
-
243
- export const openApi = {
244
- summary: 'Update a <entity>',
245
- tags: ['<Module Name>'],
189
+ export const metadata = {
190
+ GET: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.view'] },
191
+ POST: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.manage'] },
192
+ PUT: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.manage'] },
193
+ DELETE: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.manage'] },
246
194
  }
247
- ```
248
-
249
- ### DELETE Route
250
195
 
251
- **File**: `src/modules/<module_id>/api/delete/<entities>.ts`
252
-
253
- ```typescript
254
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
255
- import { <Entity> } from '../../data/entities'
256
-
257
- const handler = makeCrudRoute({
258
- entity: <Entity>,
259
- entityId: '<module_id>.<entity>',
260
- operations: ['delete'],
196
+ const crud = makeCrudRoute({
197
+ metadata,
198
+ orm: {
199
+ entity: <Entity>,
200
+ idField: 'id',
201
+ orgField: 'organizationId',
202
+ tenantField: 'tenantId',
203
+ },
204
+ indexer: { entityType: '<module_id>.<entity>' },
205
+ list: {
206
+ schema: list<Entity>Schema,
207
+ entityId: '<module_id>.<entity>',
208
+ fields: ['id', 'name', 'organization_id', 'tenant_id', 'created_at', 'updated_at'],
209
+ },
210
+ create: { schema: create<Entity>Schema },
211
+ update: { schema: update<Entity>Schema },
212
+ del: {},
261
213
  })
262
214
 
263
- export default handler
215
+ export const { GET, POST, PUT, DELETE } = crud
264
216
 
265
217
  export const openApi = {
266
- summary: 'Delete a <entity>',
218
+ summary: '<Entity> CRUD',
267
219
  tags: ['<Module Name>'],
268
220
  }
269
221
  ```
270
222
 
271
223
  ### Rules
272
224
 
273
- - Every API route MUST export `openApi` for documentation generation
274
- - Use `makeCrudRoute` with `indexer: { entityType }` for query engine coverage
275
- - Schema validation is automatic when `schema` is provided
276
- - Auth guards are applied automatically by the framework
225
+ - All HTTP methods MUST live in a single `api/<entities>/route.ts` file
226
+ - MUST export `metadata` missing it silently breaks route-level auth guards
227
+ - MUST export `openApi` for documentation generation
228
+ - MUST use `makeCrudRoute` with `indexer: { entityType }` for query engine coverage
229
+ - Use `orm`, `list`, `create`, `update`, `del` keys — `entity`/`entityId`/`operations`/`schema` at root level are not valid
277
230
 
278
231
  ---
279
232
 
@@ -311,29 +264,94 @@ export const metadata = {
311
264
 
312
265
  ```tsx
313
266
  'use client'
267
+ import * as React from 'react'
268
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
314
269
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
270
+ import type { ColumnDef } from '@tanstack/react-table'
271
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
272
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
273
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
315
274
  import { useT } from '@open-mercato/shared/lib/i18n/context'
316
275
 
276
+ type <Entity> = { id: string; name: string; organizationId: string; tenantId: string }
277
+
278
+ type <Entity>ListResponse = {
279
+ items: <Entity>[]
280
+ total: number
281
+ page: number
282
+ pageSize: number
283
+ totalPages: number
284
+ }
285
+
286
+ const PAGE_SIZE = 20
287
+
317
288
  export default function <Module>ListPage() {
318
289
  const t = useT()
290
+ const scopeVersion = useOrganizationScopeVersion()
291
+ const [rows, setRows] = React.useState<<Entity>[]>([])
292
+ const [page, setPage] = React.useState(1)
293
+ const [total, setTotal] = React.useState(0)
294
+ const [totalPages, setTotalPages] = React.useState(1)
295
+ const [isLoading, setIsLoading] = React.useState(true)
296
+
297
+ const columns = React.useMemo<ColumnDef<<Entity>>[]>(() => [
298
+ { accessorKey: 'name', header: t('<module_id>.list.columns.name') },
299
+ ], [t])
300
+
301
+ React.useEffect(() => {
302
+ let cancelled = false
303
+ async function load() {
304
+ setIsLoading(true)
305
+ try {
306
+ const params = new URLSearchParams()
307
+ params.set('page', String(page))
308
+ params.set('pageSize', String(PAGE_SIZE))
309
+ const fallback: <Entity>ListResponse = { items: [], total: 0, page, pageSize: PAGE_SIZE, totalPages: 1 }
310
+ const call = await apiCall<<Entity>ListResponse>(
311
+ `/api/<module_id>/<entities>?${params.toString()}`,
312
+ undefined,
313
+ { fallback },
314
+ )
315
+ if (!call.ok) {
316
+ flash(t('<module_id>.list.error.loadFailed'), 'error')
317
+ return
318
+ }
319
+ const payload = call.result ?? fallback
320
+ if (!cancelled) {
321
+ setRows(Array.isArray(payload.items) ? payload.items : [])
322
+ setTotal(payload.total || 0)
323
+ setTotalPages(payload.totalPages || 1)
324
+ }
325
+ } catch (err) {
326
+ if (!cancelled) {
327
+ flash(err instanceof Error ? err.message : t('<module_id>.list.error.loadFailed'), 'error')
328
+ }
329
+ } finally {
330
+ if (!cancelled) setIsLoading(false)
331
+ }
332
+ }
333
+ load()
334
+ return () => { cancelled = true }
335
+ }, [page, scopeVersion, t])
319
336
 
320
337
  return (
321
- <DataTable
322
- entityId="<module_id>.<entity>"
323
- apiPath="<module_id>/<entities>"
324
- title={t('<module_id>.list.title')}
325
- createHref="/backend/<module_id>/<entities>/new"
326
- columns={[
327
- { id: 'name', header: t('<module_id>.fields.name'), accessorKey: 'name' },
328
- // Add more columns
329
- ]}
330
- />
338
+ <Page>
339
+ <PageBody>
340
+ <DataTable<<Entity>>
341
+ title={t('<module_id>.list.title')}
342
+ columns={columns}
343
+ data={rows}
344
+ isLoading={isLoading}
345
+ pagination={{ page, pageSize: PAGE_SIZE, total, totalPages, onPageChange: setPage }}
346
+ />
347
+ </PageBody>
348
+ </Page>
331
349
  )
332
350
  }
333
351
 
334
352
  export const metadata = {
335
353
  requireAuth: true,
336
- requireFeatures: ['<module_id>.view'],
354
+ requireFeatures: ['<module_id>.<entity>.view'],
337
355
  pageTitle: '<Module Name>',
338
356
  pageTitleKey: '<module_id>.nav.title',
339
357
  pageGroup: '<Module Name>',
@@ -348,30 +366,40 @@ export const metadata = {
348
366
 
349
367
  ```tsx
350
368
  'use client'
369
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
351
370
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
371
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
372
+ import { useRouter } from 'next/navigation'
352
373
  import { useT } from '@open-mercato/shared/lib/i18n/context'
353
374
 
375
+ type <Entity> = { id: string; name: string }
376
+
354
377
  export default function Create<Entity>Page() {
355
378
  const t = useT()
379
+ const router = useRouter()
356
380
 
357
381
  return (
358
- <CrudForm
359
- entityId="<module_id>.<entity>"
360
- apiPath="<module_id>/<entities>"
361
- mode="create"
362
- title={t('<module_id>.create.title')}
363
- fields={[
364
- { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
365
- // Add more fields
366
- ]}
367
- backHref="/backend/<module_id>"
368
- />
382
+ <Page>
383
+ <PageBody>
384
+ <CrudForm
385
+ title={t('<module_id>.create.title')}
386
+ backHref="/backend/<module_id>"
387
+ fields={[
388
+ { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
389
+ ]}
390
+ onSubmit={async (values) => {
391
+ const { result } = await createCrud<<Entity>>('<module_id>/<entities>', values)
392
+ router.push(`/backend/<module_id>/<entities>/${result.id}`)
393
+ }}
394
+ />
395
+ </PageBody>
396
+ </Page>
369
397
  )
370
398
  }
371
399
 
372
400
  export const metadata = {
373
401
  requireAuth: true,
374
- requireFeatures: ['<module_id>.create'],
402
+ requireFeatures: ['<module_id>.<entity>.manage'],
375
403
  pageTitle: 'Create <Entity>',
376
404
  pageTitleKey: '<module_id>.create.title',
377
405
  pageGroup: '<Module Name>',
@@ -386,35 +414,58 @@ export const metadata = {
386
414
 
387
415
  ```tsx
388
416
  'use client'
417
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
389
418
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
419
+ import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
420
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
421
+ import { useQuery } from '@tanstack/react-query'
422
+ import { useRouter } from 'next/navigation'
390
423
  import { useT } from '@open-mercato/shared/lib/i18n/context'
391
424
 
425
+ type <Entity> = { id: string; name: string }
426
+ type <Entity>DetailResponse = { items: <Entity>[]; total: number; page: number; pageSize: number; totalPages: number }
427
+
392
428
  export default function Edit<Entity>Page({ params }: { params: { id: string } }) {
393
429
  const t = useT()
430
+ const router = useRouter()
431
+ const { data: response, isLoading } = useQuery({
432
+ queryKey: ['<module_id>', '<entities>', params.id],
433
+ queryFn: () => apiCall<<Entity>DetailResponse>(`<module_id>/<entities>?id=${params.id}`),
434
+ })
394
435
 
395
436
  return (
396
- <CrudForm
397
- entityId="<module_id>.<entity>"
398
- apiPath="<module_id>/<entities>"
399
- mode="edit"
400
- resourceId={params.id}
401
- title={t('<module_id>.edit.title')}
402
- fields={[
403
- { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
404
- // Add more fields
405
- ]}
406
- backHref="/backend/<module_id>"
407
- />
437
+ <Page>
438
+ <PageBody>
439
+ <CrudForm
440
+ title={t('<module_id>.edit.title')}
441
+ backHref="/backend/<module_id>"
442
+ fields={[
443
+ { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
444
+ ]}
445
+ isLoading={isLoading}
446
+ initialValues={response?.items?.[0] ?? undefined}
447
+ onSubmit={async (values) => {
448
+ await updateCrud('<module_id>/<entities>', { id: params.id, ...values })
449
+ router.push('/backend/<module_id>')
450
+ }}
451
+ onDelete={async () => {
452
+ await deleteCrud('<module_id>/<entities>', params.id)
453
+ router.push('/backend/<module_id>')
454
+ }}
455
+ />
456
+ </PageBody>
457
+ </Page>
408
458
  )
409
459
  }
410
460
 
411
461
  export const metadata = {
412
462
  requireAuth: true,
413
- requireFeatures: ['<module_id>.update'],
463
+ requireFeatures: ['<module_id>.<entity>.manage'],
414
464
  pageTitle: 'Edit <Entity>',
415
465
  pageTitleKey: '<module_id>.edit.title',
416
466
  pageGroup: '<Module Name>',
417
467
  pageGroupKey: '<module_id>.nav.group',
468
+ navHidden: true,
418
469
  }
419
470
  ```
420
471
 
@@ -447,11 +498,11 @@ export { features } from './acl'
447
498
 
448
499
  ```typescript
449
500
  export const features = [
450
- { id: '<module_id>.view', title: 'View <entities>', module: '<module_id>' },
451
- { id: '<module_id>.create', title: 'Create <entities>', module: '<module_id>' },
452
- { id: '<module_id>.update', title: 'Update <entities>', module: '<module_id>' },
453
- { id: '<module_id>.delete', title: 'Delete <entities>', module: '<module_id>' },
501
+ { id: '<module_id>.<entity>.view', title: 'View <entities>', module: '<module_id>' },
502
+ { id: '<module_id>.<entity>.manage', title: 'Manage <entities>', module: '<module_id>' },
454
503
  ]
504
+
505
+ export default features
455
506
  ```
456
507
 
457
508
  ### Setup (Tenant Init + Default Roles)
@@ -463,9 +514,9 @@ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
463
514
 
464
515
  export const setup: ModuleSetupConfig = {
465
516
  defaultRoleFeatures: {
466
- superadmin: ['<module_id>.view', '<module_id>.create', '<module_id>.update', '<module_id>.delete'],
467
- admin: ['<module_id>.view', '<module_id>.create', '<module_id>.update', '<module_id>.delete'],
468
- user: ['<module_id>.view'],
517
+ superadmin: ['<module_id>.<entity>.view', '<module_id>.<entity>.manage'],
518
+ admin: ['<module_id>.<entity>.view', '<module_id>.<entity>.manage'],
519
+ user: ['<module_id>.<entity>.view'],
469
520
  },
470
521
  }
471
522
 
@@ -474,9 +525,11 @@ export default setup
474
525
 
475
526
  ### Rules
476
527
 
477
- - Feature IDs follow `<module_id>.<action>` pattern
528
+ - Feature IDs follow `<module_id>.<entity>.<action>` (view / manage per entity, not global create/update/delete)
529
+ - Add `export default features` — the generator reads `.default ?? .features` with an empty fallback, so the named export alone works, but adding the default export ensures both import styles resolve cleanly
478
530
  - MUST declare `defaultRoleFeatures` for every feature in `acl.ts`
479
531
  - Feature IDs are FROZEN once deployed — cannot rename without data migration
532
+ - After adding features run `yarn mercato auth sync-role-acls` so existing tenants receive the grants
480
533
 
481
534
  ---
482
535
 
@@ -506,28 +559,25 @@ export function register(container: AppContainer): void {
506
559
  ```typescript
507
560
  import { createModuleEvents } from '@open-mercato/shared/modules/events'
508
561
 
509
- export const eventsConfig = createModuleEvents({
510
- '<module_id>.<entity>.created': {
511
- description: '<Entity> was created',
512
- payload: { resourceId: 'string', name: 'string' },
513
- },
514
- '<module_id>.<entity>.updated': {
515
- description: '<Entity> was updated',
516
- payload: { resourceId: 'string' },
517
- },
518
- '<module_id>.<entity>.deleted': {
519
- description: '<Entity> was deleted',
520
- payload: { resourceId: 'string' },
521
- },
522
- } as const)
562
+ const events = [
563
+ { id: '<module_id>.<entity>.created', label: '<Entity> Created', entity: '<entity>', category: 'crud' as const },
564
+ { id: '<module_id>.<entity>.updated', label: '<Entity> Updated', entity: '<entity>', category: 'crud' as const },
565
+ { id: '<module_id>.<entity>.deleted', label: '<Entity> Deleted', entity: '<entity>', category: 'crud' as const },
566
+ ] as const
567
+
568
+ export const eventsConfig = createModuleEvents({ moduleId: '<module_id>', events })
569
+ export const emit<Module>Event = eventsConfig.emit
570
+ export type <Module>EventId = typeof events[number]['id']
571
+ export default eventsConfig
523
572
  ```
524
573
 
525
574
  ### Event Rules
526
575
 
527
- - Event IDs: `module.entity.action` (singular entity, past tense action)
528
- - Use dots as separators
529
- - Payload fields are additive-only (FROZEN contract)
530
- - Add `clientBroadcast: true` to bridge events to browser via SSE
576
+ - `createModuleEvents` takes `{ moduleId, events }` — NOT a flat keyed object. Using the old keyed-object shape crashes `/login` at startup because the generated events registry cannot read the module
577
+ - Event IDs: `module.entity.action` (singular entity, past tense action, dots as separators)
578
+ - Declare `label`, `entity`, and `category` on each event — they populate the workflow trigger UI
579
+ - Add `clientBroadcast: true` to an event definition to bridge it to the browser via SSE
580
+ - Event ID contracts are FROZEN once deployed — adding new events is safe; renaming or removing is a breaking change
531
581
 
532
582
  ---
533
583
 
@@ -575,6 +625,41 @@ export default function registerCli(program: any) {
575
625
  }
576
626
  ```
577
627
 
628
+ ### Response Enrichers
629
+
630
+ Use enrichers to add computed fields to another module's API responses without coupling the modules.
631
+
632
+ **File**: `src/modules/<module_id>/data/enrichers.ts`
633
+
634
+ ```typescript
635
+ import type { ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher'
636
+
637
+ const <entity>Enricher: ResponseEnricher = {
638
+ id: '<module_id>.<entity>-enricher',
639
+ targetEntity: '<other_module>.<entity>',
640
+ features: ['<module_id>.<entity>.view'],
641
+ timeout: 2000,
642
+ fallback: { _<module_id>: {} },
643
+ async enrichOne(record, context) {
644
+ return { ...record, _<module_id>: { /* computed fields */ } }
645
+ },
646
+ async enrichMany(records, context) {
647
+ return records.map(r => ({ ...r, _<module_id>: { /* computed fields */ } }))
648
+ },
649
+ }
650
+
651
+ export const enrichers: ResponseEnricher[] = [<entity>Enricher]
652
+ ```
653
+
654
+ **Rules:**
655
+ - MUST implement `enrichOne` (required by the `ResponseEnricher` interface)
656
+ - MUST implement `enrichMany` for list endpoints to prevent N+1 queries
657
+ - Namespace enriched fields with `_<module_id>` prefix
658
+ - The target route must opt in: `makeCrudRoute({ ..., enrichers: { entityId: '<other_module>.<entity>' } })`
659
+ - Run `yarn generate` after adding `data/enrichers.ts`
660
+
661
+ ---
662
+
578
663
  ### Encryption maps (sensitive / GDPR-relevant fields)
579
664
 
580
665
  **Mandatory** when the entity stores PII, contact info, addresses, free-text notes about people, integration credentials, secrets, or anything subject to a data-processing agreement. Do NOT hand-roll AES, KMS calls, or "TODO encrypt later" stubs — the framework provides per-tenant DEKs and a declarative field-level map.
@@ -657,7 +742,27 @@ yarn db:migrate # Apply migration only after explicit user confirmation
657
742
  yarn dev # Start dev server
658
743
  ```
659
744
 
660
- ### Step 5: Verify
745
+ ### Step 5: Run Post-Scaffold Validation Gate
746
+
747
+ After every structural module change, run **in order** before committing:
748
+
749
+ ```bash
750
+ # 1. Re-emit generated registries with the new module
751
+ yarn generate
752
+
753
+ # 2. Purge stale structural cache (nav, module-graph fingerprints)
754
+ yarn mercato configs cache structural --all-tenants
755
+
756
+ # 3. Grant ACL features declared in acl.ts to existing roles
757
+ yarn mercato auth sync-role-acls
758
+
759
+ # 4. Type-check all files — catches API mismatches before they reach runtime
760
+ yarn typecheck
761
+ ```
762
+
763
+ > **Why this matters**: A malformed `events.ts` (for example, using the old keyed-object shape for `createModuleEvents`) will crash `/login` and every other page because generated registries import all active module files at startup. A bad scaffold can make the whole admin inaccessible. Running `yarn typecheck` after `yarn generate` catches this before it ships.
764
+
765
+ ### Step 6: Verify
661
766
 
662
767
  - [ ] Module appears in admin sidebar (if menu item added)
663
768
  - [ ] List page loads at `/backend/<module_id>`
@@ -665,26 +770,35 @@ yarn dev # Start dev server
665
770
  - [ ] Edit form loads existing record
666
771
  - [ ] Delete works from list page
667
772
  - [ ] ACL features appear in role management
773
+ - [ ] `/login` still loads after structural changes
668
774
 
669
775
  ### Self-Review Checklist
670
776
 
671
777
  - [ ] Module ID is plural, snake_case
672
778
  - [ ] Entity class has `organization_id`, `tenant_id`, standard columns
673
779
  - [ ] Validators use zod with `z.infer` for types
674
- - [ ] All API routes export `openApi`
675
- - [ ] Backend pages use `CrudForm` and `DataTable`
780
+ - [ ] API routes live in `api/<entities>/route.ts` (not `api/get/`, `api/post/`, etc.)
781
+ - [ ] `makeCrudRoute` uses `{ metadata, orm, list, create, update, del }` — not `{ entity, entityId, operations, schema }`
782
+ - [ ] API route exports `metadata`, named `{ GET, POST, PUT, DELETE }`, and `openApi`
783
+ - [ ] `DataTable` receives explicit `data`, `isLoading`, `error`, `pagination` — not `apiPath` or `createHref`
784
+ - [ ] `CrudForm` uses `onSubmit` with `createCrud`/`updateCrud` and `onDelete` with `deleteCrud` — not `apiPath`, `mode`, or `resourceId`
785
+ - [ ] `events.ts` uses `createModuleEvents({ moduleId, events: [...] })` array shape — not a keyed object
786
+ - [ ] `events.ts` has `export default eventsConfig`
787
+ - [ ] `acl.ts` exports `features` (named export is sufficient; default export is recommended for broad import compatibility)
788
+ - [ ] ACL feature IDs use `<module>.<entity>.view` / `<module>.<entity>.manage` pattern
789
+ - [ ] `setup.ts` grants every feature in `acl.ts` to at least `admin` and `superadmin`
676
790
  - [ ] Sidebar icon uses `lucide-react` component (not inline SVG / `React.createElement`)
677
791
  - [ ] `page.meta.ts` includes `pageGroup` + `pageGroupKey` for sidebar grouping
678
792
  - [ ] `page.meta.ts` includes `pageOrder` for sort position
679
793
  - [ ] All related pages share the same `pageGroupKey`
680
794
  - [ ] Settings pages (if any) have `pageContext: 'settings' as const` and `navHidden: true`
681
- - [ ] ACL features declared and wired in `setup.ts`
682
795
  - [ ] Module registered in `src/modules.ts` with `from: '@app'`
683
- - [ ] `yarn generate` run after creating files
796
+ - [ ] Post-scaffold gate run: `yarn generate` `yarn mercato configs cache structural --all-tenants` → `yarn mercato auth sync-role-acls` → `yarn typecheck`
684
797
  - [ ] Migration SQL is scoped to this entity and `.snapshot-open-mercato.json` is updated
685
798
  - [ ] No `any` types
686
799
  - [ ] No hardcoded user-facing strings
687
800
  - [ ] No direct ORM relationships to other modules
801
+ - [ ] `/login` still loads after all changes
688
802
 
689
803
  ---
690
804
 
@@ -694,13 +808,21 @@ yarn dev # Start dev server
694
808
  - **MUST** include `organization_id` and `tenant_id` on all tenant-scoped entities
695
809
  - **MUST** include standard columns (`id`, `created_at`, `updated_at`, `deleted_at`, `is_active`)
696
810
  - **MUST** validate all inputs with zod schemas in `data/validators.ts`
697
- - **MUST** export `openApi` from every API route
698
- - **MUST** use `CrudForm` for forms and `DataTable` for tables
811
+ - **MUST** place all HTTP method handlers in a single `api/<entities>/route.ts` not separate `api/get/`, `api/post/` files
812
+ - **MUST** use `makeCrudRoute` with `{ metadata, orm, list, create, update, del }` — not `{ entity, entityId, operations, schema }`
813
+ - **MUST** export `metadata`, named method handlers `{ GET, POST, PUT, DELETE }`, and `openApi` from every route file
814
+ - **MUST** use `CrudForm` with explicit `onSubmit` / `onDelete` handlers — not `apiPath`, `mode`, or `resourceId` props
815
+ - **MUST** use `DataTable` with explicit `data`, `isLoading`, `error`, `pagination` — not `apiPath`, `createHref`, or `extensionTableId`
816
+ - **MUST** use `createModuleEvents({ moduleId, events: [...] })` array shape — NEVER the old keyed-object `{ 'id': { description, payload } }` shape
817
+ - **MUST** add `export default eventsConfig` in `events.ts`
818
+ - **MUST** export `features` from `acl.ts` (named export is sufficient; adding `export default features` is recommended for broad import compatibility)
819
+ - **MUST** use `<module>.<entity>.view` / `<module>.<entity>.manage` feature ID pattern
699
820
  - **MUST** include `pageGroup` and `pageGroupKey` on list/root backend pages for sidebar grouping
700
821
  - **MUST** use `as const` on `pageContext` values (e.g., `pageContext: 'settings' as const`)
701
822
  - **MUST** declare ACL features and wire them in `setup.ts` `defaultRoleFeatures`
702
823
  - **MUST** register module in `src/modules.ts` with `from: '@app'`
703
- - **MUST** run `yarn generate` after creating module files
824
+ - **MUST** run the post-scaffold validation gate after creating module files: `yarn generate` `yarn mercato configs cache structural --all-tenants` → `yarn mercato auth sync-role-acls` → `yarn typecheck`
825
+ - **MUST** verify `/login` still loads after every structural change
704
826
  - **MUST** create or keep a scoped migration after creating/modifying entities and update `.snapshot-open-mercato.json`
705
827
  - **MUST NOT** commit unrelated migrations emitted by `yarn db:generate`
706
828
  - **MUST NOT** run `yarn db:migrate` without explicit user confirmation
@@ -63,14 +63,8 @@ src/modules/<module_id>/
63
63
  │ ├── entities.ts # MikroORM entity classes
64
64
  │ └── validators.ts # Zod validation schemas
65
65
  ├── api/
66
- ├── get/
67
- └── <entities>.ts # GET /api/<module>/<entities> (list + detail)
68
- │ ├── post/
69
- │ │ └── <entities>.ts # POST /api/<module>/<entities>
70
- │ ├── put/
71
- │ │ └── <entities>.ts # PUT /api/<module>/<entities>
72
- │ └── delete/
73
- │ └── <entities>.ts # DELETE /api/<module>/<entities>
66
+ └── <entities>/
67
+ └── route.ts # All HTTP methods in one file: GET, POST, PUT, DELETE
74
68
  └── backend/
75
69
  ├── page.tsx # List page → /backend/<module>
76
70
  ├── <entities>/
@@ -150,6 +144,11 @@ export class <Entity> {
150
144
  ```typescript
151
145
  import { z } from 'zod'
152
146
 
147
+ export const list<Entity>Schema = z.object({
148
+ search: z.string().optional(),
149
+ id: z.string().uuid().optional(),
150
+ })
151
+
153
152
  export const create<Entity>Schema = z.object({
154
153
  name: z.string().min(1).max(255),
155
154
  // Add domain fields matching entity
@@ -159,6 +158,7 @@ export const update<Entity>Schema = create<Entity>Schema.partial().extend({
159
158
  id: z.string().uuid(),
160
159
  })
161
160
 
161
+ export type List<Entity>Query = z.infer<typeof list<Entity>Schema>
162
162
  export type Create<Entity>Input = z.infer<typeof create<Entity>Schema>
163
163
  export type Update<Entity>Input = z.infer<typeof update<Entity>Schema>
164
164
  ```
@@ -173,107 +173,60 @@ export type Update<Entity>Input = z.infer<typeof update<Entity>Schema>
173
173
 
174
174
  ## 5. Create API Routes
175
175
 
176
- Use `makeCrudRoute` for standard CRUD. Each HTTP method lives in its own file.
177
-
178
- ### GET Route
179
-
180
- **File**: `src/modules/<module_id>/api/get/<entities>.ts`
181
-
182
- ```typescript
183
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
184
- import { <Entity> } from '../../data/entities'
185
-
186
- const handler = makeCrudRoute({
187
- entity: <Entity>,
188
- entityId: '<module_id>.<entity>',
189
- operations: ['list', 'detail'],
190
- indexer: { entityType: '<module_id>.<entity>' },
191
- })
192
-
193
- export default handler
194
-
195
- export const openApi = {
196
- summary: 'List and retrieve <entities>',
197
- tags: ['<Module Name>'],
198
- }
199
- ```
200
-
201
- ### POST Route
202
-
203
- **File**: `src/modules/<module_id>/api/post/<entities>.ts`
204
-
205
- ```typescript
206
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
207
- import { <Entity> } from '../../data/entities'
208
- import { create<Entity>Schema } from '../../data/validators'
209
-
210
- const handler = makeCrudRoute({
211
- entity: <Entity>,
212
- entityId: '<module_id>.<entity>',
213
- operations: ['create'],
214
- schema: create<Entity>Schema,
215
- })
216
-
217
- export default handler
218
-
219
- export const openApi = {
220
- summary: 'Create a <entity>',
221
- tags: ['<Module Name>'],
222
- }
223
- ```
224
-
225
- ### PUT Route
176
+ Use `makeCrudRoute` for standard CRUD. All HTTP methods live in a single `route.ts` file.
226
177
 
227
- **File**: `src/modules/<module_id>/api/put/<entities>.ts`
178
+ **File**: `src/modules/<module_id>/api/<entities>/route.ts`
228
179
 
229
180
  ```typescript
230
181
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
231
182
  import { <Entity> } from '../../data/entities'
232
- import { update<Entity>Schema } from '../../data/validators'
183
+ import {
184
+ list<Entity>Schema,
185
+ create<Entity>Schema,
186
+ update<Entity>Schema,
187
+ } from '../../data/validators'
233
188
 
234
- const handler = makeCrudRoute({
235
- entity: <Entity>,
236
- entityId: '<module_id>.<entity>',
237
- operations: ['update'],
238
- schema: update<Entity>Schema,
239
- })
240
-
241
- export default handler
242
-
243
- export const openApi = {
244
- summary: 'Update a <entity>',
245
- tags: ['<Module Name>'],
189
+ export const metadata = {
190
+ GET: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.view'] },
191
+ POST: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.manage'] },
192
+ PUT: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.manage'] },
193
+ DELETE: { requireAuth: true, requireFeatures: ['<module_id>.<entity>.manage'] },
246
194
  }
247
- ```
248
-
249
- ### DELETE Route
250
195
 
251
- **File**: `src/modules/<module_id>/api/delete/<entities>.ts`
252
-
253
- ```typescript
254
- import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
255
- import { <Entity> } from '../../data/entities'
256
-
257
- const handler = makeCrudRoute({
258
- entity: <Entity>,
259
- entityId: '<module_id>.<entity>',
260
- operations: ['delete'],
196
+ const crud = makeCrudRoute({
197
+ metadata,
198
+ orm: {
199
+ entity: <Entity>,
200
+ idField: 'id',
201
+ orgField: 'organizationId',
202
+ tenantField: 'tenantId',
203
+ },
204
+ indexer: { entityType: '<module_id>.<entity>' },
205
+ list: {
206
+ schema: list<Entity>Schema,
207
+ entityId: '<module_id>.<entity>',
208
+ fields: ['id', 'name', 'organization_id', 'tenant_id', 'created_at', 'updated_at'],
209
+ },
210
+ create: { schema: create<Entity>Schema },
211
+ update: { schema: update<Entity>Schema },
212
+ del: {},
261
213
  })
262
214
 
263
- export default handler
215
+ export const { GET, POST, PUT, DELETE } = crud
264
216
 
265
217
  export const openApi = {
266
- summary: 'Delete a <entity>',
218
+ summary: '<Entity> CRUD',
267
219
  tags: ['<Module Name>'],
268
220
  }
269
221
  ```
270
222
 
271
223
  ### Rules
272
224
 
273
- - Every API route MUST export `openApi` for documentation generation
274
- - Use `makeCrudRoute` with `indexer: { entityType }` for query engine coverage
275
- - Schema validation is automatic when `schema` is provided
276
- - Auth guards are applied automatically by the framework
225
+ - All HTTP methods MUST live in a single `api/<entities>/route.ts` file
226
+ - MUST export `metadata` missing it silently breaks route-level auth guards
227
+ - MUST export `openApi` for documentation generation
228
+ - MUST use `makeCrudRoute` with `indexer: { entityType }` for query engine coverage
229
+ - Use `orm`, `list`, `create`, `update`, `del` keys — `entity`/`entityId`/`operations`/`schema` at root level are not valid
277
230
 
278
231
  ---
279
232
 
@@ -311,29 +264,94 @@ export const metadata = {
311
264
 
312
265
  ```tsx
313
266
  'use client'
267
+ import * as React from 'react'
268
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
314
269
  import { DataTable } from '@open-mercato/ui/backend/DataTable'
270
+ import type { ColumnDef } from '@tanstack/react-table'
271
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
272
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
273
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
315
274
  import { useT } from '@open-mercato/shared/lib/i18n/context'
316
275
 
276
+ type <Entity> = { id: string; name: string; organizationId: string; tenantId: string }
277
+
278
+ type <Entity>ListResponse = {
279
+ items: <Entity>[]
280
+ total: number
281
+ page: number
282
+ pageSize: number
283
+ totalPages: number
284
+ }
285
+
286
+ const PAGE_SIZE = 20
287
+
317
288
  export default function <Module>ListPage() {
318
289
  const t = useT()
290
+ const scopeVersion = useOrganizationScopeVersion()
291
+ const [rows, setRows] = React.useState<<Entity>[]>([])
292
+ const [page, setPage] = React.useState(1)
293
+ const [total, setTotal] = React.useState(0)
294
+ const [totalPages, setTotalPages] = React.useState(1)
295
+ const [isLoading, setIsLoading] = React.useState(true)
296
+
297
+ const columns = React.useMemo<ColumnDef<<Entity>>[]>(() => [
298
+ { accessorKey: 'name', header: t('<module_id>.list.columns.name') },
299
+ ], [t])
300
+
301
+ React.useEffect(() => {
302
+ let cancelled = false
303
+ async function load() {
304
+ setIsLoading(true)
305
+ try {
306
+ const params = new URLSearchParams()
307
+ params.set('page', String(page))
308
+ params.set('pageSize', String(PAGE_SIZE))
309
+ const fallback: <Entity>ListResponse = { items: [], total: 0, page, pageSize: PAGE_SIZE, totalPages: 1 }
310
+ const call = await apiCall<<Entity>ListResponse>(
311
+ `/api/<module_id>/<entities>?${params.toString()}`,
312
+ undefined,
313
+ { fallback },
314
+ )
315
+ if (!call.ok) {
316
+ flash(t('<module_id>.list.error.loadFailed'), 'error')
317
+ return
318
+ }
319
+ const payload = call.result ?? fallback
320
+ if (!cancelled) {
321
+ setRows(Array.isArray(payload.items) ? payload.items : [])
322
+ setTotal(payload.total || 0)
323
+ setTotalPages(payload.totalPages || 1)
324
+ }
325
+ } catch (err) {
326
+ if (!cancelled) {
327
+ flash(err instanceof Error ? err.message : t('<module_id>.list.error.loadFailed'), 'error')
328
+ }
329
+ } finally {
330
+ if (!cancelled) setIsLoading(false)
331
+ }
332
+ }
333
+ load()
334
+ return () => { cancelled = true }
335
+ }, [page, scopeVersion, t])
319
336
 
320
337
  return (
321
- <DataTable
322
- entityId="<module_id>.<entity>"
323
- apiPath="<module_id>/<entities>"
324
- title={t('<module_id>.list.title')}
325
- createHref="/backend/<module_id>/<entities>/new"
326
- columns={[
327
- { id: 'name', header: t('<module_id>.fields.name'), accessorKey: 'name' },
328
- // Add more columns
329
- ]}
330
- />
338
+ <Page>
339
+ <PageBody>
340
+ <DataTable<<Entity>>
341
+ title={t('<module_id>.list.title')}
342
+ columns={columns}
343
+ data={rows}
344
+ isLoading={isLoading}
345
+ pagination={{ page, pageSize: PAGE_SIZE, total, totalPages, onPageChange: setPage }}
346
+ />
347
+ </PageBody>
348
+ </Page>
331
349
  )
332
350
  }
333
351
 
334
352
  export const metadata = {
335
353
  requireAuth: true,
336
- requireFeatures: ['<module_id>.view'],
354
+ requireFeatures: ['<module_id>.<entity>.view'],
337
355
  pageTitle: '<Module Name>',
338
356
  pageTitleKey: '<module_id>.nav.title',
339
357
  pageGroup: '<Module Name>',
@@ -348,30 +366,40 @@ export const metadata = {
348
366
 
349
367
  ```tsx
350
368
  'use client'
369
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
351
370
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
371
+ import { createCrud } from '@open-mercato/ui/backend/utils/crud'
372
+ import { useRouter } from 'next/navigation'
352
373
  import { useT } from '@open-mercato/shared/lib/i18n/context'
353
374
 
375
+ type <Entity> = { id: string; name: string }
376
+
354
377
  export default function Create<Entity>Page() {
355
378
  const t = useT()
379
+ const router = useRouter()
356
380
 
357
381
  return (
358
- <CrudForm
359
- entityId="<module_id>.<entity>"
360
- apiPath="<module_id>/<entities>"
361
- mode="create"
362
- title={t('<module_id>.create.title')}
363
- fields={[
364
- { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
365
- // Add more fields
366
- ]}
367
- backHref="/backend/<module_id>"
368
- />
382
+ <Page>
383
+ <PageBody>
384
+ <CrudForm
385
+ title={t('<module_id>.create.title')}
386
+ backHref="/backend/<module_id>"
387
+ fields={[
388
+ { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
389
+ ]}
390
+ onSubmit={async (values) => {
391
+ const { result } = await createCrud<<Entity>>('<module_id>/<entities>', values)
392
+ router.push(`/backend/<module_id>/<entities>/${result.id}`)
393
+ }}
394
+ />
395
+ </PageBody>
396
+ </Page>
369
397
  )
370
398
  }
371
399
 
372
400
  export const metadata = {
373
401
  requireAuth: true,
374
- requireFeatures: ['<module_id>.create'],
402
+ requireFeatures: ['<module_id>.<entity>.manage'],
375
403
  pageTitle: 'Create <Entity>',
376
404
  pageTitleKey: '<module_id>.create.title',
377
405
  pageGroup: '<Module Name>',
@@ -386,35 +414,58 @@ export const metadata = {
386
414
 
387
415
  ```tsx
388
416
  'use client'
417
+ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
389
418
  import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
419
+ import { updateCrud, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
420
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
421
+ import { useQuery } from '@tanstack/react-query'
422
+ import { useRouter } from 'next/navigation'
390
423
  import { useT } from '@open-mercato/shared/lib/i18n/context'
391
424
 
425
+ type <Entity> = { id: string; name: string }
426
+ type <Entity>DetailResponse = { items: <Entity>[]; total: number; page: number; pageSize: number; totalPages: number }
427
+
392
428
  export default function Edit<Entity>Page({ params }: { params: { id: string } }) {
393
429
  const t = useT()
430
+ const router = useRouter()
431
+ const { data: response, isLoading } = useQuery({
432
+ queryKey: ['<module_id>', '<entities>', params.id],
433
+ queryFn: () => apiCall<<Entity>DetailResponse>(`<module_id>/<entities>?id=${params.id}`),
434
+ })
394
435
 
395
436
  return (
396
- <CrudForm
397
- entityId="<module_id>.<entity>"
398
- apiPath="<module_id>/<entities>"
399
- mode="edit"
400
- resourceId={params.id}
401
- title={t('<module_id>.edit.title')}
402
- fields={[
403
- { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
404
- // Add more fields
405
- ]}
406
- backHref="/backend/<module_id>"
407
- />
437
+ <Page>
438
+ <PageBody>
439
+ <CrudForm
440
+ title={t('<module_id>.edit.title')}
441
+ backHref="/backend/<module_id>"
442
+ fields={[
443
+ { id: 'name', label: t('<module_id>.fields.name'), type: 'text', required: true },
444
+ ]}
445
+ isLoading={isLoading}
446
+ initialValues={response?.items?.[0] ?? undefined}
447
+ onSubmit={async (values) => {
448
+ await updateCrud('<module_id>/<entities>', { id: params.id, ...values })
449
+ router.push('/backend/<module_id>')
450
+ }}
451
+ onDelete={async () => {
452
+ await deleteCrud('<module_id>/<entities>', params.id)
453
+ router.push('/backend/<module_id>')
454
+ }}
455
+ />
456
+ </PageBody>
457
+ </Page>
408
458
  )
409
459
  }
410
460
 
411
461
  export const metadata = {
412
462
  requireAuth: true,
413
- requireFeatures: ['<module_id>.update'],
463
+ requireFeatures: ['<module_id>.<entity>.manage'],
414
464
  pageTitle: 'Edit <Entity>',
415
465
  pageTitleKey: '<module_id>.edit.title',
416
466
  pageGroup: '<Module Name>',
417
467
  pageGroupKey: '<module_id>.nav.group',
468
+ navHidden: true,
418
469
  }
419
470
  ```
420
471
 
@@ -447,11 +498,11 @@ export { features } from './acl'
447
498
 
448
499
  ```typescript
449
500
  export const features = [
450
- { id: '<module_id>.view', title: 'View <entities>', module: '<module_id>' },
451
- { id: '<module_id>.create', title: 'Create <entities>', module: '<module_id>' },
452
- { id: '<module_id>.update', title: 'Update <entities>', module: '<module_id>' },
453
- { id: '<module_id>.delete', title: 'Delete <entities>', module: '<module_id>' },
501
+ { id: '<module_id>.<entity>.view', title: 'View <entities>', module: '<module_id>' },
502
+ { id: '<module_id>.<entity>.manage', title: 'Manage <entities>', module: '<module_id>' },
454
503
  ]
504
+
505
+ export default features
455
506
  ```
456
507
 
457
508
  ### Setup (Tenant Init + Default Roles)
@@ -463,9 +514,9 @@ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
463
514
 
464
515
  export const setup: ModuleSetupConfig = {
465
516
  defaultRoleFeatures: {
466
- superadmin: ['<module_id>.view', '<module_id>.create', '<module_id>.update', '<module_id>.delete'],
467
- admin: ['<module_id>.view', '<module_id>.create', '<module_id>.update', '<module_id>.delete'],
468
- user: ['<module_id>.view'],
517
+ superadmin: ['<module_id>.<entity>.view', '<module_id>.<entity>.manage'],
518
+ admin: ['<module_id>.<entity>.view', '<module_id>.<entity>.manage'],
519
+ user: ['<module_id>.<entity>.view'],
469
520
  },
470
521
  }
471
522
 
@@ -474,9 +525,11 @@ export default setup
474
525
 
475
526
  ### Rules
476
527
 
477
- - Feature IDs follow `<module_id>.<action>` pattern
528
+ - Feature IDs follow `<module_id>.<entity>.<action>` (view / manage per entity, not global create/update/delete)
529
+ - Add `export default features` — the generator reads `.default ?? .features` with an empty fallback, so the named export alone works, but adding the default export ensures both import styles resolve cleanly
478
530
  - MUST declare `defaultRoleFeatures` for every feature in `acl.ts`
479
531
  - Feature IDs are FROZEN once deployed — cannot rename without data migration
532
+ - After adding features run `yarn mercato auth sync-role-acls` so existing tenants receive the grants
480
533
 
481
534
  ---
482
535
 
@@ -506,28 +559,25 @@ export function register(container: AppContainer): void {
506
559
  ```typescript
507
560
  import { createModuleEvents } from '@open-mercato/shared/modules/events'
508
561
 
509
- export const eventsConfig = createModuleEvents({
510
- '<module_id>.<entity>.created': {
511
- description: '<Entity> was created',
512
- payload: { resourceId: 'string', name: 'string' },
513
- },
514
- '<module_id>.<entity>.updated': {
515
- description: '<Entity> was updated',
516
- payload: { resourceId: 'string' },
517
- },
518
- '<module_id>.<entity>.deleted': {
519
- description: '<Entity> was deleted',
520
- payload: { resourceId: 'string' },
521
- },
522
- } as const)
562
+ const events = [
563
+ { id: '<module_id>.<entity>.created', label: '<Entity> Created', entity: '<entity>', category: 'crud' as const },
564
+ { id: '<module_id>.<entity>.updated', label: '<Entity> Updated', entity: '<entity>', category: 'crud' as const },
565
+ { id: '<module_id>.<entity>.deleted', label: '<Entity> Deleted', entity: '<entity>', category: 'crud' as const },
566
+ ] as const
567
+
568
+ export const eventsConfig = createModuleEvents({ moduleId: '<module_id>', events })
569
+ export const emit<Module>Event = eventsConfig.emit
570
+ export type <Module>EventId = typeof events[number]['id']
571
+ export default eventsConfig
523
572
  ```
524
573
 
525
574
  ### Event Rules
526
575
 
527
- - Event IDs: `module.entity.action` (singular entity, past tense action)
528
- - Use dots as separators
529
- - Payload fields are additive-only (FROZEN contract)
530
- - Add `clientBroadcast: true` to bridge events to browser via SSE
576
+ - `createModuleEvents` takes `{ moduleId, events }` — NOT a flat keyed object. Using the old keyed-object shape crashes `/login` at startup because the generated events registry cannot read the module
577
+ - Event IDs: `module.entity.action` (singular entity, past tense action, dots as separators)
578
+ - Declare `label`, `entity`, and `category` on each event — they populate the workflow trigger UI
579
+ - Add `clientBroadcast: true` to an event definition to bridge it to the browser via SSE
580
+ - Event ID contracts are FROZEN once deployed — adding new events is safe; renaming or removing is a breaking change
531
581
 
532
582
  ---
533
583
 
@@ -575,6 +625,41 @@ export default function registerCli(program: any) {
575
625
  }
576
626
  ```
577
627
 
628
+ ### Response Enrichers
629
+
630
+ Use enrichers to add computed fields to another module's API responses without coupling the modules.
631
+
632
+ **File**: `src/modules/<module_id>/data/enrichers.ts`
633
+
634
+ ```typescript
635
+ import type { ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher'
636
+
637
+ const <entity>Enricher: ResponseEnricher = {
638
+ id: '<module_id>.<entity>-enricher',
639
+ targetEntity: '<other_module>.<entity>',
640
+ features: ['<module_id>.<entity>.view'],
641
+ timeout: 2000,
642
+ fallback: { _<module_id>: {} },
643
+ async enrichOne(record, context) {
644
+ return { ...record, _<module_id>: { /* computed fields */ } }
645
+ },
646
+ async enrichMany(records, context) {
647
+ return records.map(r => ({ ...r, _<module_id>: { /* computed fields */ } }))
648
+ },
649
+ }
650
+
651
+ export const enrichers: ResponseEnricher[] = [<entity>Enricher]
652
+ ```
653
+
654
+ **Rules:**
655
+ - MUST implement `enrichOne` (required by the `ResponseEnricher` interface)
656
+ - MUST implement `enrichMany` for list endpoints to prevent N+1 queries
657
+ - Namespace enriched fields with `_<module_id>` prefix
658
+ - The target route must opt in: `makeCrudRoute({ ..., enrichers: { entityId: '<other_module>.<entity>' } })`
659
+ - Run `yarn generate` after adding `data/enrichers.ts`
660
+
661
+ ---
662
+
578
663
  ### Encryption maps (sensitive / GDPR-relevant fields)
579
664
 
580
665
  **Mandatory** when the entity stores PII, contact info, addresses, free-text notes about people, integration credentials, secrets, or anything subject to a data-processing agreement. Do NOT hand-roll AES, KMS calls, or "TODO encrypt later" stubs — the framework provides per-tenant DEKs and a declarative field-level map.
@@ -657,7 +742,27 @@ yarn db:migrate # Apply migration only after explicit user confirmation
657
742
  yarn dev # Start dev server
658
743
  ```
659
744
 
660
- ### Step 5: Verify
745
+ ### Step 5: Run Post-Scaffold Validation Gate
746
+
747
+ After every structural module change, run **in order** before committing:
748
+
749
+ ```bash
750
+ # 1. Re-emit generated registries with the new module
751
+ yarn generate
752
+
753
+ # 2. Purge stale structural cache (nav, module-graph fingerprints)
754
+ yarn mercato configs cache structural --all-tenants
755
+
756
+ # 3. Grant ACL features declared in acl.ts to existing roles
757
+ yarn mercato auth sync-role-acls
758
+
759
+ # 4. Type-check all files — catches API mismatches before they reach runtime
760
+ yarn typecheck
761
+ ```
762
+
763
+ > **Why this matters**: A malformed `events.ts` (for example, using the old keyed-object shape for `createModuleEvents`) will crash `/login` and every other page because generated registries import all active module files at startup. A bad scaffold can make the whole admin inaccessible. Running `yarn typecheck` after `yarn generate` catches this before it ships.
764
+
765
+ ### Step 6: Verify
661
766
 
662
767
  - [ ] Module appears in admin sidebar (if menu item added)
663
768
  - [ ] List page loads at `/backend/<module_id>`
@@ -665,26 +770,35 @@ yarn dev # Start dev server
665
770
  - [ ] Edit form loads existing record
666
771
  - [ ] Delete works from list page
667
772
  - [ ] ACL features appear in role management
773
+ - [ ] `/login` still loads after structural changes
668
774
 
669
775
  ### Self-Review Checklist
670
776
 
671
777
  - [ ] Module ID is plural, snake_case
672
778
  - [ ] Entity class has `organization_id`, `tenant_id`, standard columns
673
779
  - [ ] Validators use zod with `z.infer` for types
674
- - [ ] All API routes export `openApi`
675
- - [ ] Backend pages use `CrudForm` and `DataTable`
780
+ - [ ] API routes live in `api/<entities>/route.ts` (not `api/get/`, `api/post/`, etc.)
781
+ - [ ] `makeCrudRoute` uses `{ metadata, orm, list, create, update, del }` — not `{ entity, entityId, operations, schema }`
782
+ - [ ] API route exports `metadata`, named `{ GET, POST, PUT, DELETE }`, and `openApi`
783
+ - [ ] `DataTable` receives explicit `data`, `isLoading`, `error`, `pagination` — not `apiPath` or `createHref`
784
+ - [ ] `CrudForm` uses `onSubmit` with `createCrud`/`updateCrud` and `onDelete` with `deleteCrud` — not `apiPath`, `mode`, or `resourceId`
785
+ - [ ] `events.ts` uses `createModuleEvents({ moduleId, events: [...] })` array shape — not a keyed object
786
+ - [ ] `events.ts` has `export default eventsConfig`
787
+ - [ ] `acl.ts` exports `features` (named export is sufficient; default export is recommended for broad import compatibility)
788
+ - [ ] ACL feature IDs use `<module>.<entity>.view` / `<module>.<entity>.manage` pattern
789
+ - [ ] `setup.ts` grants every feature in `acl.ts` to at least `admin` and `superadmin`
676
790
  - [ ] Sidebar icon uses `lucide-react` component (not inline SVG / `React.createElement`)
677
791
  - [ ] `page.meta.ts` includes `pageGroup` + `pageGroupKey` for sidebar grouping
678
792
  - [ ] `page.meta.ts` includes `pageOrder` for sort position
679
793
  - [ ] All related pages share the same `pageGroupKey`
680
794
  - [ ] Settings pages (if any) have `pageContext: 'settings' as const` and `navHidden: true`
681
- - [ ] ACL features declared and wired in `setup.ts`
682
795
  - [ ] Module registered in `src/modules.ts` with `from: '@app'`
683
- - [ ] `yarn generate` run after creating files
796
+ - [ ] Post-scaffold gate run: `yarn generate` `yarn mercato configs cache structural --all-tenants` → `yarn mercato auth sync-role-acls` → `yarn typecheck`
684
797
  - [ ] Migration SQL is scoped to this entity and `.snapshot-open-mercato.json` is updated
685
798
  - [ ] No `any` types
686
799
  - [ ] No hardcoded user-facing strings
687
800
  - [ ] No direct ORM relationships to other modules
801
+ - [ ] `/login` still loads after all changes
688
802
 
689
803
  ---
690
804
 
@@ -694,13 +808,21 @@ yarn dev # Start dev server
694
808
  - **MUST** include `organization_id` and `tenant_id` on all tenant-scoped entities
695
809
  - **MUST** include standard columns (`id`, `created_at`, `updated_at`, `deleted_at`, `is_active`)
696
810
  - **MUST** validate all inputs with zod schemas in `data/validators.ts`
697
- - **MUST** export `openApi` from every API route
698
- - **MUST** use `CrudForm` for forms and `DataTable` for tables
811
+ - **MUST** place all HTTP method handlers in a single `api/<entities>/route.ts` not separate `api/get/`, `api/post/` files
812
+ - **MUST** use `makeCrudRoute` with `{ metadata, orm, list, create, update, del }` — not `{ entity, entityId, operations, schema }`
813
+ - **MUST** export `metadata`, named method handlers `{ GET, POST, PUT, DELETE }`, and `openApi` from every route file
814
+ - **MUST** use `CrudForm` with explicit `onSubmit` / `onDelete` handlers — not `apiPath`, `mode`, or `resourceId` props
815
+ - **MUST** use `DataTable` with explicit `data`, `isLoading`, `error`, `pagination` — not `apiPath`, `createHref`, or `extensionTableId`
816
+ - **MUST** use `createModuleEvents({ moduleId, events: [...] })` array shape — NEVER the old keyed-object `{ 'id': { description, payload } }` shape
817
+ - **MUST** add `export default eventsConfig` in `events.ts`
818
+ - **MUST** export `features` from `acl.ts` (named export is sufficient; adding `export default features` is recommended for broad import compatibility)
819
+ - **MUST** use `<module>.<entity>.view` / `<module>.<entity>.manage` feature ID pattern
699
820
  - **MUST** include `pageGroup` and `pageGroupKey` on list/root backend pages for sidebar grouping
700
821
  - **MUST** use `as const` on `pageContext` values (e.g., `pageContext: 'settings' as const`)
701
822
  - **MUST** declare ACL features and wire them in `setup.ts` `defaultRoleFeatures`
702
823
  - **MUST** register module in `src/modules.ts` with `from: '@app'`
703
- - **MUST** run `yarn generate` after creating module files
824
+ - **MUST** run the post-scaffold validation gate after creating module files: `yarn generate` `yarn mercato configs cache structural --all-tenants` → `yarn mercato auth sync-role-acls` → `yarn typecheck`
825
+ - **MUST** verify `/login` still loads after every structural change
704
826
  - **MUST** create or keep a scoped migration after creating/modifying entities and update `.snapshot-open-mercato.json`
705
827
  - **MUST NOT** commit unrelated migrations emitted by `yarn db:generate`
706
828
  - **MUST NOT** run `yarn db:migrate` without explicit user confirmation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-mercato-app",
3
- "version": "0.6.4-develop.4264.1.53368d85fe",
3
+ "version": "0.6.4-develop.4282.1.4d95e85930",
4
4
  "type": "module",
5
5
  "description": "Create a new Open Mercato application",
6
6
  "main": "./dist/index.js",
@@ -505,9 +505,9 @@ When building a new application or a new module under `src/modules/<id>/`, do no
505
505
  | Backend admin pages | Auto-discovered files under `backend/**` with paired `page.meta.ts` (`requireAuth`, `requireFeatures`, `pageGroup`, `pageGroupKey`, `pageOrder`) | <https://docs.open-mercato.dev/framework/modules/routes-and-pages> |
506
506
  | Frontend public pages and customer portal | Auto-discovered files under `frontend/**`. Portal pages live at `frontend/[orgSlug]/portal/<path>/page.tsx` with `requireCustomerAuth` / `requireCustomerFeatures` | <https://docs.open-mercato.dev/framework/modules/routes-and-pages> |
507
507
  | API routes (auth + OpenAPI) | `src/modules/<id>/api/**/route.ts` exporting handlers + `metadata` (per-method `requireAuth` / `requireFeatures`) + `openApi` | <https://docs.open-mercato.dev/framework/api/api-development-guide> |
508
- | CRUD APIs (factory) | `makeCrudRoute({ entity, entityId, operations, schema, indexer: { entityType } })` from `@open-mercato/shared/lib/crud/factory` | <https://docs.open-mercato.dev/framework/api/crud-factory> |
509
- | CRUD forms in admin | `<CrudForm entityId apiPath mode fields />` from `@open-mercato/ui/backend/CrudForm`; helpers `createCrud` / `updateCrud` / `deleteCrud` from `@open-mercato/ui/backend/utils/crud`; `createCrudFormError` from `@open-mercato/ui/backend/utils/serverErrors`. Never raw `<form>` or raw `fetch` | <https://docs.open-mercato.dev/framework/admin-ui/crud-form> |
510
- | DataTables in admin | `<DataTable entityId apiPath columns />` from `@open-mercato/ui/backend/DataTable`; keep `entityId` and `extensionTableId` stable so widget injection (columns, row actions, filters, toolbar) keeps working | <https://docs.open-mercato.dev/framework/admin-ui/data-grids> |
508
+ | CRUD APIs (factory) | `makeCrudRoute({ metadata, orm, list, create, update, del, indexer })` from `@open-mercato/shared/lib/crud/factory` — all methods in one `api/<entities>/route.ts`, export named `{ GET, POST, PUT, DELETE }` and `metadata` | <https://docs.open-mercato.dev/framework/api/crud-factory> |
509
+ | CRUD forms in admin | `<CrudForm fields onSubmit onDelete />` from `@open-mercato/ui/backend/CrudForm`; use `createCrud` / `updateCrud` / `deleteCrud` from `@open-mercato/ui/backend/utils/crud` in the handlers; `createCrudFormError` from `@open-mercato/ui/backend/utils/serverErrors`. Never `apiPath`, `mode`, or `resourceId` props. Never raw `<form>` or raw `fetch` | <https://docs.open-mercato.dev/framework/admin-ui/crud-form> |
510
+ | DataTables in admin | `<DataTable columns data isLoading error pagination />` from `@open-mercato/ui/backend/DataTable`; fetch data with `apiCall` + `useQuery` and pass it explicitly — no built-in `apiPath` data-fetching prop. Use optional `entityId` only for widget injection slot targeting | <https://docs.open-mercato.dev/framework/admin-ui/data-grids> |
511
511
  | Authorization (RBAC) | Declare features in `<module>/acl.ts`, grant in `<module>/setup.ts` `defaultRoleFeatures`, gate routes/pages with `requireFeatures` in `metadata`. NEVER use `requireRoles`. Run `yarn mercato auth sync-role-acls` after adding features | <https://docs.open-mercato.dev/framework/rbac/overview> |
512
512
  | Multi-tenant scoping (default) | Every tenant-scoped entity MUST include indexed `organization_id` and `tenant_id`; every read/write filters by them. The CRUD factory injects the scope automatically — do not bypass it | <https://docs.open-mercato.dev/architecture/system-overview> |
513
513
  | **Encryption maps for sensitive data** | Declare `<module>/encryption.ts` exporting `defaultEncryptionMaps: ModuleEncryptionMap[]`; read via `findWithDecryption` / `findOneWithDecryption`. NEVER hand-roll AES/KMS — see the next section | <https://docs.open-mercato.dev/user-guide/encryption> |