create-mercato-app 0.6.4-develop.4264.1.53368d85fe → 0.6.4-develop.4270.1.a614eb18e6
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
|
-
│
|
|
67
|
-
│
|
|
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.
|
|
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
|
|
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 {
|
|
183
|
+
import {
|
|
184
|
+
list<Entity>Schema,
|
|
185
|
+
create<Entity>Schema,
|
|
186
|
+
update<Entity>Schema,
|
|
187
|
+
} from '../../data/validators'
|
|
233
188
|
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
215
|
+
export const { GET, POST, PUT, DELETE } = crud
|
|
264
216
|
|
|
265
217
|
export const openApi = {
|
|
266
|
-
summary: '
|
|
218
|
+
summary: '<Entity> CRUD',
|
|
267
219
|
tags: ['<Module Name>'],
|
|
268
220
|
}
|
|
269
221
|
```
|
|
270
222
|
|
|
271
223
|
### Rules
|
|
272
224
|
|
|
273
|
-
-
|
|
274
|
-
-
|
|
275
|
-
-
|
|
276
|
-
-
|
|
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
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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>.
|
|
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
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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>.
|
|
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',
|
|
451
|
-
{ id: '<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>.
|
|
467
|
-
admin:
|
|
468
|
-
user:
|
|
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>`
|
|
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
|
-
|
|
510
|
-
'<module_id>.<entity>.created':
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
-
|
|
528
|
-
-
|
|
529
|
-
-
|
|
530
|
-
- Add `clientBroadcast: true` to bridge
|
|
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:
|
|
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
|
-
- [ ]
|
|
675
|
-
- [ ]
|
|
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`
|
|
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**
|
|
698
|
-
- **MUST** use `
|
|
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`
|
|
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
|
-
│
|
|
67
|
-
│
|
|
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.
|
|
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
|
|
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 {
|
|
183
|
+
import {
|
|
184
|
+
list<Entity>Schema,
|
|
185
|
+
create<Entity>Schema,
|
|
186
|
+
update<Entity>Schema,
|
|
187
|
+
} from '../../data/validators'
|
|
233
188
|
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
215
|
+
export const { GET, POST, PUT, DELETE } = crud
|
|
264
216
|
|
|
265
217
|
export const openApi = {
|
|
266
|
-
summary: '
|
|
218
|
+
summary: '<Entity> CRUD',
|
|
267
219
|
tags: ['<Module Name>'],
|
|
268
220
|
}
|
|
269
221
|
```
|
|
270
222
|
|
|
271
223
|
### Rules
|
|
272
224
|
|
|
273
|
-
-
|
|
274
|
-
-
|
|
275
|
-
-
|
|
276
|
-
-
|
|
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
|
-
<
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
<
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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>.
|
|
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
|
-
<
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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>.
|
|
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',
|
|
451
|
-
{ id: '<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>.
|
|
467
|
-
admin:
|
|
468
|
-
user:
|
|
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>`
|
|
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
|
-
|
|
510
|
-
'<module_id>.<entity>.created':
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
-
|
|
528
|
-
-
|
|
529
|
-
-
|
|
530
|
-
- Add `clientBroadcast: true` to bridge
|
|
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:
|
|
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
|
-
- [ ]
|
|
675
|
-
- [ ]
|
|
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`
|
|
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**
|
|
698
|
-
- **MUST** use `
|
|
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`
|
|
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
package/template/AGENTS.md
CHANGED
|
@@ -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({
|
|
509
|
-
| CRUD forms in admin | `<CrudForm
|
|
510
|
-
| DataTables in admin | `<DataTable
|
|
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> |
|