@goplusvn/core 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,33 +1,59 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.13Customizer: make radius, density & inset/floating actually apply
3
+ ## 0.1.15Shared UI: ConfirmDialog, StatBar, ListToolbar
4
+
5
+ Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog, StatBar, ListToolbar + AdvancedField) — import từ sub-barrel tránh circular. Additive; wu shim + verify render 200.
6
+
7
+ ## 0.1.14 — Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
8
+
9
+ Đúc kết từ việc dựng app mới (wu-vpbank): những thứ MỖI app phải tự viết lại nay
10
+ đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
11
+ compatible** — 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
12
+
13
+ - **Server-CRUD engine** (`@goerp/core/crud/server`): `createServerCrudService`
14
+ (Prisma-agnostic qua DI), `getModelName`, `createAuditUserNameResolver` (schema-
15
+ tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
16
+ Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
17
+ - **Auth request-gate** (`@goerp/core/auth/proxy-gate`): `createAuthProxy` (default-
18
+ deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
19
+ - **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
20
+ (`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) → app schema
21
+ khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
22
+ - **Shared UI** (`@goerp/core/ui`): PageHeader, StatusIndicator + getStatusMeta,
23
+ SumFooterCell + sumBy, table-styles (mẫu bảng đơn bán hàng, subpath server-safe
24
+ `@goerp/core/ui/shared/table-styles`); export DynamicIcon.
25
+
26
+ Verify: core tsc 0 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
27
+ golden vinhhoa tsc 20=20 (0 regression).
28
+
29
+ ## 0.1.13 — Customizer: make radius, density & inset/floating actually apply
4
30
 
5
31
  The Customizer exposed controls that stored a value but changed nothing on
6
32
  screen. Wired them up at the source so every app using core gets them working:
7
33
 
8
- - **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
34
+ - **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
9
35
  `--radius` token from `settings.radius`; Tailwind's `@theme inline` derives
10
- `rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 square,
11
- 1rem fully rounded). Previously nothing read `settings.radius`.
12
- - **Density ("Mật độ") now applies.** `SettingsProvider` sets
36
+ `rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 → square,
37
+ 1rem → fully rounded). Previously nothing read `settings.radius`.
38
+ - **Density ("Mật độ") now applies.** `SettingsProvider` sets
13
39
  `data-density="compact"` on `<html>`; `base.css` tightens the global
14
- `--spacing` scale (0.25rem 0.215rem) so every spacing utility packs in
40
+ `--spacing` scale (0.25rem → 0.215rem) so every spacing utility packs in
15
41
  compact mode. Previously `useDensity()` was read nowhere.
16
42
  - **Inset / floating sidebar no longer renders a harsh frame.** Core's default
17
43
  sidebar is deep navy, and shadcn's inset/floating paints the page frame with
18
- that sidebar colour reading as a broken-looking block. The frame is now the
44
+ that sidebar colour — reading as a broken-looking block. The frame is now the
19
45
  soft `--muted` neutral, so the content floats as a card on a calm page
20
46
  (adapts to dark mode; the rounded-xl + shadow core already applies stay).
21
47
  - **Inset / floating collapsed rail no longer looks squeezed.** In icon mode the
22
48
  floating/inset content box is `--sidebar-width-icon + 8px` (56px) but had
23
- `px-2.5` padding, leaving the pill only 36px narrower than the default 48px
49
+ `px-2.5` padding, leaving the pill only 36px — narrower than the default 48px
24
50
  rail. Reduced to `px-1` so the pill is a comfortable 48px with a 4px floating
25
51
  gutter.
26
52
  - **Inset / floating sidebar corners are softened.** The floating panel was
27
53
  `rounded-lg` and the inset panel had NO radius (a sharp navy block whose
28
- top-right corner met the header as a hard 90°). Both now use `rounded-xl`
54
+ top-right corner met the header as a hard 90°). Both now use `rounded-xl`
29
55
  (matching the inset content card) so the sidebar reads as a soft floating
30
- panel the header/sidebar junction is a gentle curve, not a rough corner.
56
+ panel — the header/sidebar junction is a gentle curve, not a rough corner.
31
57
  The floating content area also rounds both left corners
32
58
  (`rounded-l-xl`, clipping the sticky header + footer via overflow-hidden) so
33
59
  its whole left edge hugs the floating sidebar with soft curves; the right
@@ -38,20 +64,20 @@ screen. Wired them up at the source so every app using core gets them working:
38
64
  And the content no longer touches the sidebar: inset drops its `ml-0` (so the
39
65
  uniform `m-2` leaves a left gap) and floating gains `ml-2`, giving a small
40
66
  gap between the sidebar and the content/header.
41
- - **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
42
- `AppSidebar` force-remapped `offcanvas icon`, so the option collapsed to an
43
- icon rail instead of hiding identical to "Biểu tượng" and impossible to fully
67
+ - **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
68
+ `AppSidebar` force-remapped `offcanvas → icon`, so the option collapsed to an
69
+ icon rail instead of hiding — identical to "Biểu tượng" and impossible to fully
44
70
  hide the sidebar. The choice is now honoured as-is: offcanvas slides the
45
- sidebar off-screen (reopen via the header toggle), icon rail.
46
- - **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
47
- the sidebar expanded made the header collapse-toggle a no-op a
71
+ sidebar off-screen (reopen via the header toggle), icon → rail.
72
+ - **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
73
+ the sidebar expanded made the header collapse-toggle a no-op — a
48
74
  conflicting/dead control. The picker now offers only offcanvas + icon, both of
49
75
  which the toggle actually drives. (`none` remains a valid type for any code
50
76
  that sets it directly; it's just no longer offered in the UI.)
51
- - **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
77
+ - **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
52
78
  Customizer.** The horizontal layout swaps the whole nav for a top menubar,
53
- which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
54
- a no-op the same dead-control problem. The customizer now locks to the
79
+ which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
80
+ a no-op — the same dead-control problem. The customizer now locks to the
55
81
  vertical sidebar the apps are designed around (`defaultSettings.layout` stays
56
82
  `vertical`; `HorizontalLayout` is untouched for code that sets it directly).
57
83
  - **Non-collapsing sidebar (`collapsible="none"`) is now readable.** It rendered
@@ -59,9 +85,9 @@ screen. Wired them up at the source so every app using core gets them working:
59
85
  active item are `text-primary`, giving ~1.9:1 contrast (unreadable) on that
60
86
  navy. A non-collapsing sidebar is permanently *expanded*, so it now uses the
61
87
  expanded surface (`bg-background` / `text-foreground`), matching the rest of
62
- the design (labels-on-light) contrast jumps to ~9:1.
88
+ the design (labels-on-light) — contrast jumps to ~9:1.
63
89
 
64
- ## 0.1.11 print: force light colours on the print surface
90
+ ## 0.1.11 — print: force light colours on the print surface
65
91
 
66
92
  - **Fix faded print output in dark mode.** Print pages render under the app's
67
93
  shared `<html class="dark">`, so the print surface inherited dark mode's light
@@ -70,34 +96,34 @@ screen. Wired them up at the source so every app using core gets them working:
70
96
  `.print-container, #print-content`, keeping every shared print template
71
97
  black-on-white regardless of the active theme.
72
98
 
73
- ## 0.1.8 branding wiring + auth/rbac unification + clean typecheck
99
+ ## 0.1.8 — branding wiring + auth/rbac unification + clean typecheck
74
100
 
75
101
  - **Single source for permission checks (security fix).** `@goerp/core/rbac`
76
102
  used to ship its own copy of `checkPermission` / `getUserPermissions` /
77
- `getCrudPermissionsFromSession` / which had drifted from `@goerp/core/auth`
78
- it still allowed `BYPASS_AUTH` in production and denied admins with no
103
+ `getCrudPermissionsFromSession` / … which had drifted from `@goerp/core/auth`
104
+ — it still allowed `BYPASS_AUTH` in production and denied admins with no
79
105
  explicit permission rows. `rbac` now re-exports these from the hardened `auth`
80
106
  module (bypass dev-only, admin-before-empty-guard), so the two can't diverge.
81
107
 
82
108
 
83
109
  - **Branding via `TenantProvider`.** `Logo`/`LogoCompact` now resolve their image,
84
- company name and tagline from `props TenantProvider branding defaults`, so a
110
+ company name and tagline from `props → TenantProvider branding → defaults`, so a
85
111
  new app re-skins by wrapping with `<TenantProvider tenant={{ branding }}>` (or
86
112
  passing `logoSrc`/`companyName`/`tagline` props) instead of hardcoding. Added a
87
113
  non-throwing `useOptionalTenant()` hook and a `tagline` field to `TenantBranding`.
88
114
  `TenantProvider` also applies `branding.primaryColor` (an HSL triple) to
89
115
  `--primary`/`--accent-primary` at runtime, so the brand color is config-driven
90
- too. Fully backward compatible (no provider/props previous GoERP defaults).
116
+ too. Fully backward compatible (no provider/props → previous GoERP defaults).
91
117
  - **Core now type-checks clean (0 errors).** Fixed the `system/categories`
92
118
  components that imported the consumer alias `@goerp/core/ui` from inside the
93
- package (unresolvable in core's own tsconfig cascaded into implicit-any in
119
+ package (unresolvable in core's own tsconfig → cascaded into implicit-any in
94
120
  their event handlers); switched to relative imports.
95
121
  - **Standard entity configs.** `@goerp/core/configs/entities` now ships canonical
96
122
  `EntityConfig`s (starting with `departmentsConfig`, plus the previously
97
123
  unexported `materialCategoriesConfig`) so apps import + spread-to-override
98
124
  instead of copy-pasting near-identical configs.
99
125
 
100
- ## 0.1.7 auth hardening
126
+ ## 0.1.7 — auth hardening
101
127
 
102
128
  Hardened `@goerp/core/auth` so the Vinh Hoa app (and others) can consolidate
103
129
  their hand-copied RBAC clones onto core without regressing two safety behaviors:
@@ -109,46 +135,46 @@ their hand-copied RBAC clones onto core without regressing two safety behaviors:
109
135
  admin-role bypass BEFORE the empty-permissions guard, so an admin who relies on
110
136
  their role (no explicit permission rows) is no longer denied everything.
111
137
 
112
- ## 0.1.6 platform foundation extraction
138
+ ## 0.1.6 — platform foundation extraction
113
139
 
114
140
  Promoted the cross-cutting platform layer out of the Vinh Hoa reference app into
115
141
  core so every app inherits the same design system + infrastructure and only
116
- builds its own business logic. All additive existing subpaths are unchanged.
142
+ builds its own business logic. All additive — existing subpaths are unchanged.
117
143
 
118
144
  ### Added
119
145
 
120
- - **`@goerp/core/styles/base.css`** the canonical design-system tokens (Radix
121
- scale shadcn semantic Plane semantic; status, charts, sidebar, scrollbar,
146
+ - **`@goerp/core/styles/base.css`** — the canonical design-system tokens (Radix
147
+ scale → shadcn semantic → Plane semantic; status, charts, sidebar, scrollbar,
122
148
  animations). Apps `@import` this from their Tailwind v4 entry stylesheet and
123
149
  override only brand tokens (`--primary`, `--sidebar-*`). Brand default: indigo.
124
150
  Also ships `@goerp/core/styles/radix-themes.css` (the HSL Radix scale).
125
151
 
126
- - **`@goerp/core/errors`** `AppError` + `toAppError` (maps Prisma
127
- P2002/P2025/P2003/P2014 friendly message + stable `errorId`),
152
+ - **`@goerp/core/errors`** — `AppError` + `toAppError` (maps Prisma
153
+ P2002/P2025/P2003/P2014 → friendly message + stable `errorId`),
128
154
  `withErrorHandler` / `createErrorResponse` / `errorResponse`, and the
129
155
  db-injected factories `createErrorLogger(db)` (fingerprint-deduped persistence
130
156
  to an `error_logs` table) + `buildServerError({ logServerError })` /
131
157
  `createServerError({ db })`. Granular client-safe paths:
132
158
  `@goerp/core/errors/{app-error,error-handler,server-error}`.
133
159
 
134
- - **`@goerp/core/export`** `useExport()` (the unified "Xuất Excel" hook),
160
+ - **`@goerp/core/export`** — `useExport()` (the unified "Xuất Excel" hook),
135
161
  `triggerBlobDownload` / `filenameFromContentDisposition`, and `toCsv` /
136
162
  `escapeCsvField` (RFC 4180). The crud `exportToCSV` now delegates to `toCsv`,
137
163
  fixing unescaped commas/quotes/newlines and falsy `0`/`false`. Granular paths:
138
164
  `@goerp/core/export/{to-csv,download-file,use-export}`.
139
165
 
140
- - **`@goerp/core/print`** `DirectPrintProvider` (document-agnostic print device
166
+ - **`@goerp/core/print`** — `DirectPrintProvider` (document-agnostic print device
141
167
  layer: hidden-iframe URL printing with a mobile same-tab fallback, one-page
142
168
  sizing, loading overlay) driven by a `renderContent` render-prop, the
143
169
  `useDirectPrint` hook, and `PrintStyles` (A4/A5 `@media print`). Granular paths:
144
170
  `@goerp/core/print/{direct-print-provider,print-styles}`.
145
171
 
146
- - **`@goerp/core/repository`** `casUpdate` / `casUpdateById`: the compare-and-
172
+ - **`@goerp/core/repository`** — `casUpdate` / `casUpdateById`: the compare-and-
147
173
  swap invariant for money/stock mutations (`updateMany` on expected state,
148
174
  throw on zero rows so an enclosing transaction rolls back).
149
175
 
150
- - **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`**
151
- `serializeDecimalFields` (Prisma Decimal string for Client Components),
176
+ - **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`** —
177
+ `serializeDecimalFields` (Prisma Decimal → string for Client Components),
152
178
  `swrFetcher` / `swrListFetcher` / `unwrapList`, Vietnam-timezone date helpers,
153
179
  and the CCCD (VN ID-card) QR parser.
154
180
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.13",
4
+ "version": "0.1.15",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -36,6 +36,8 @@
36
36
  },
37
37
  "./assets/*": "./src/assets/*",
38
38
  "./styles/*": "./src/styles/*",
39
+ "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
40
+ "./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
39
41
  "./errors/app-error": "./src/errors/app-error.ts",
40
42
  "./errors/error-handler": "./src/errors/error-handler.ts",
41
43
  "./errors/server-error": "./src/errors/server-error.ts",
@@ -0,0 +1,80 @@
1
+ // @goerp/core/auth/proxy-gate — server-only request-gate for the Next.js
2
+ // proxy/middleware. Default-DENY authentication (authN); route-level authZ still
3
+ // happens via getCrudPermissions/checkPermission. Isolated in its own subpath so
4
+ // `next/server` + `next-auth/jwt` never leak into client bundles via the auth barrel.
5
+ //
6
+ // Usage (app side):
7
+ // // src/proxy.ts
8
+ // import { createAuthProxy } from "@goerp/core/auth/proxy-gate";
9
+ // export const proxy = createAuthProxy({ homePath: "/vi" });
10
+ // export default proxy;
11
+ // export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.[^/]+$).*)"] };
12
+
13
+ import { NextResponse } from "next/server";
14
+ import { getToken } from "next-auth/jwt";
15
+ import type { NextRequest } from "next/server";
16
+
17
+ export interface AuthProxyOptions {
18
+ /** API prefixes served without a session (NextAuth + public). Default: /api/auth, /api/public. */
19
+ publicApiPrefixes?: string[];
20
+ /** Pages reachable while logged out. Default: /sign-in. */
21
+ publicPages?: string[];
22
+ /** Where to send unauthenticated page requests. Default: /sign-in. */
23
+ signInPath?: string;
24
+ /** Where to send a logged-in user who hits a guest page. Default: "/". */
25
+ homePath?: string;
26
+ /** Override token reader (tests / custom JWT). Default: next-auth getToken. */
27
+ getToken?: (req: NextRequest) => Promise<unknown | null>;
28
+ }
29
+
30
+ const startsWithAny = (pathname: string, list: string[]) =>
31
+ list.some((p) => pathname === p || pathname.startsWith(`${p}/`));
32
+
33
+ export function createAuthProxy(options: AuthProxyOptions = {}) {
34
+ const publicApiPrefixes = options.publicApiPrefixes ?? ["/api/auth", "/api/public"];
35
+ const publicPages = options.publicPages ?? ["/sign-in"];
36
+ const signInPath = options.signInPath ?? "/sign-in";
37
+ const homePath = options.homePath ?? "/";
38
+ const readToken = options.getToken ?? ((req: NextRequest) => getToken({ req }));
39
+
40
+ return async function proxy(request: NextRequest) {
41
+ const { pathname, search } = request.nextUrl;
42
+
43
+ // API routes that authenticate themselves (NextAuth) or are public → pass.
44
+ if (startsWithAny(pathname, publicApiPrefixes)) return NextResponse.next();
45
+
46
+ const token = await readToken(request);
47
+
48
+ // Guest pages (/sign-in): bounce an already-authed user to home.
49
+ if (startsWithAny(pathname, publicPages)) {
50
+ if (token) {
51
+ const url = request.nextUrl.clone();
52
+ url.pathname = homePath;
53
+ url.search = "";
54
+ return NextResponse.redirect(url);
55
+ }
56
+ return NextResponse.next();
57
+ }
58
+
59
+ // API: default-deny (each route still does its own authZ).
60
+ if (pathname.startsWith("/api")) {
61
+ if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
62
+ return NextResponse.next();
63
+ }
64
+
65
+ // Pages: must be signed in; preserve callbackUrl.
66
+ if (!token) {
67
+ const url = request.nextUrl.clone();
68
+ url.pathname = signInPath;
69
+ url.search = "";
70
+ if (pathname !== "/") url.searchParams.set("callbackUrl", pathname + search);
71
+ return NextResponse.redirect(url);
72
+ }
73
+ return NextResponse.next();
74
+ };
75
+ }
76
+
77
+ /** 401 JSON for API route catch/guard blocks. */
78
+ export function unauthorizedResponse() {
79
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
80
+ }
@@ -0,0 +1,157 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Next.js route-handler factories for the generic CRUD engine. Wires session +
3
+ // RBAC permission gate + entity config + {@link ServerCrudService} so an app's
4
+ // route file is one line:
5
+ //
6
+ // // src/app/api/crud/[entity]/route.ts
7
+ // import { createCrudCollectionHandlers } from "@goerp/core/crud/server";
8
+ // import { getSession } from "@/lib/auth";
9
+ // import { getEntityConfig } from "@/configs/entities";
10
+ // import { crudService } from "@/lib/crud";
11
+ // export const { GET, POST } = createCrudCollectionHandlers({ getSession, getEntityConfig, service: crudService });
12
+ //
13
+ // // src/app/api/crud/[entity]/[id]/route.ts
14
+ // export const { GET, PUT, PATCH, DELETE } = createCrudItemHandlers({ getSession, getEntityConfig, service: crudService });
15
+
16
+ import type { EntityConfig } from "../types";
17
+ import { getCrudPermissions } from "./lib/permissions";
18
+ import type { ServerCrudService } from "./server-service";
19
+
20
+ type MaybePromise<T> = T | Promise<T>;
21
+
22
+ export interface CrudHandlerDeps {
23
+ getSession: () => MaybePromise<any | null>;
24
+ getEntityConfig: (entity: string) => EntityConfig | undefined;
25
+ service: ServerCrudService;
26
+ /** Optional error mapper (e.g. app's serverError). Defaults to a 500 JSON. */
27
+ onError?: (error: unknown, req: Request) => Response | Promise<Response>;
28
+ }
29
+
30
+ const json = (data: unknown, status = 200) =>
31
+ new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
32
+
33
+ const unauthorized = () => json({ error: "Unauthorized" }, 401);
34
+ const forbidden = () => json({ error: "Forbidden" }, 403);
35
+ const unknownEntity = () => json({ error: "Unknown entity" }, 404);
36
+
37
+ async function resolvePerms(session: any, config: EntityConfig, entity: string) {
38
+ return getCrudPermissions(session, config.permissionResource ?? entity);
39
+ }
40
+
41
+ // GET/POST for the collection route `/api/crud/[entity]`.
42
+ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
43
+ const { getSession, getEntityConfig, service, onError } = deps;
44
+ const fail = (e: unknown, req: Request) =>
45
+ onError ? onError(e, req) : json({ error: "Internal error" }, 500);
46
+
47
+ async function GET(req: Request, ctx: { params: Promise<{ entity: string }> }) {
48
+ const { entity } = await ctx.params;
49
+ try {
50
+ const session = await getSession();
51
+ if (!session) return unauthorized();
52
+ const config = getEntityConfig(entity);
53
+ if (!config) return unknownEntity();
54
+ const perms = await resolvePerms(session, config, entity);
55
+ if (!perms.read) return forbidden();
56
+
57
+ const sp = new URL(req.url).searchParams;
58
+ const params = {
59
+ page: parseInt(sp.get("page") || "1", 10),
60
+ pageSize: parseInt(sp.get("pageSize") || "10", 10),
61
+ search: sp.get("search") || undefined,
62
+ sort: sp.get("sortField")
63
+ ? { field: sp.get("sortField")!, direction: (sp.get("sortDirection") as "asc" | "desc") || "asc" }
64
+ : undefined,
65
+ filters: sp.get("filters") ? JSON.parse(sp.get("filters")!) : undefined,
66
+ };
67
+ const data = await service.list(entity, config, params as any);
68
+ return json(data);
69
+ } catch (e) {
70
+ return fail(e, req);
71
+ }
72
+ }
73
+
74
+ async function POST(req: Request, ctx: { params: Promise<{ entity: string }> }) {
75
+ const { entity } = await ctx.params;
76
+ try {
77
+ const session = await getSession();
78
+ if (!session) return unauthorized();
79
+ const config = getEntityConfig(entity);
80
+ if (!config) return unknownEntity();
81
+ const perms = await resolvePerms(session, config, entity);
82
+ if (!perms.create) return forbidden();
83
+
84
+ const body = await req.json();
85
+ if (Array.isArray(body)) {
86
+ const results = [];
87
+ for (const item of body) results.push(await service.create(entity, item, config));
88
+ return json(results);
89
+ }
90
+ return json(await service.create(entity, body, config));
91
+ } catch (e) {
92
+ return fail(e, req);
93
+ }
94
+ }
95
+
96
+ return { GET, POST };
97
+ }
98
+
99
+ // GET/PUT/PATCH/DELETE for the item route `/api/crud/[entity]/[id]`.
100
+ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
101
+ const { getSession, getEntityConfig, service, onError } = deps;
102
+ const fail = (e: unknown, req: Request) =>
103
+ onError ? onError(e, req) : json({ error: "Internal error" }, 500);
104
+
105
+ type Ctx = { params: Promise<{ entity: string; id: string }> };
106
+
107
+ async function GET(req: Request, ctx: Ctx) {
108
+ const { entity, id } = await ctx.params;
109
+ try {
110
+ const session = await getSession();
111
+ if (!session) return unauthorized();
112
+ const config = getEntityConfig(entity);
113
+ if (!config) return unknownEntity();
114
+ const perms = await resolvePerms(session, config, entity);
115
+ if (!perms.read) return forbidden();
116
+ const item = await service.getById(entity, id);
117
+ if (!item) return json({ error: "Not found" }, 404);
118
+ return json(item);
119
+ } catch (e) {
120
+ return fail(e, req);
121
+ }
122
+ }
123
+
124
+ async function PUT(req: Request, ctx: Ctx) {
125
+ const { entity, id } = await ctx.params;
126
+ try {
127
+ const session = await getSession();
128
+ if (!session) return unauthorized();
129
+ const config = getEntityConfig(entity);
130
+ if (!config) return unknownEntity();
131
+ const perms = await resolvePerms(session, config, entity);
132
+ if (!perms.update) return forbidden();
133
+ const body = await req.json();
134
+ return json(await service.update(entity, id, body, config));
135
+ } catch (e) {
136
+ return fail(e, req);
137
+ }
138
+ }
139
+
140
+ async function DELETE(req: Request, ctx: Ctx) {
141
+ const { entity, id } = await ctx.params;
142
+ try {
143
+ const session = await getSession();
144
+ if (!session) return unauthorized();
145
+ const config = getEntityConfig(entity);
146
+ if (!config) return unknownEntity();
147
+ const perms = await resolvePerms(session, config, entity);
148
+ if (!perms.delete) return forbidden();
149
+ await service.delete(entity, id);
150
+ return new Response(null, { status: 204 });
151
+ } catch (e) {
152
+ return fail(e, req);
153
+ }
154
+ }
155
+
156
+ return { GET, PUT, PATCH: PUT, DELETE };
157
+ }