@goplusvn/core 0.1.12 → 0.1.14
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 +83 -0
- package/package.json +3 -1
- package/src/auth/proxy-gate.ts +80 -0
- package/src/crud/crud-route-handlers.ts +157 -0
- package/src/crud/server-service.ts +312 -0
- package/src/crud/server.ts +18 -0
- package/src/providers/index.tsx +19 -0
- package/src/rbac/role-service.ts +40 -33
- package/src/styles/base.css +41 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/customizer.tsx +12 -42
- package/src/ui/layout/page-tabs.tsx +8 -42
- package/src/ui/layout/sidebar.tsx +7 -5
- package/src/ui/primitives/index.tsx +1 -0
- package/src/ui/primitives/sidebar.tsx +25 -4
- package/src/ui/shared/index.ts +6 -0
- package/src/ui/shared/page-header.tsx +57 -0
- package/src/ui/shared/status-indicator.tsx +173 -0
- package/src/ui/shared/table-styles.ts +47 -0
- package/src/ui/shared/table-sum-footer.tsx +41 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.14 — Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
|
|
4
|
+
|
|
5
|
+
Đú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
|
|
6
|
+
đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
|
|
7
|
+
compatible** — 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
|
|
8
|
+
|
|
9
|
+
- **Server-CRUD engine** (`@goerp/core/crud/server`): `createServerCrudService`
|
|
10
|
+
(Prisma-agnostic qua DI), `getModelName`, `createAuditUserNameResolver` (schema-
|
|
11
|
+
tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
|
|
12
|
+
Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
|
|
13
|
+
- **Auth request-gate** (`@goerp/core/auth/proxy-gate`): `createAuthProxy` (default-
|
|
14
|
+
deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
|
|
15
|
+
- **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
|
|
16
|
+
(`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) → app schema
|
|
17
|
+
khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
|
|
18
|
+
- **Shared UI** (`@goerp/core/ui`): PageHeader, StatusIndicator + getStatusMeta,
|
|
19
|
+
SumFooterCell + sumBy, table-styles (mẫu bảng đơn bán hàng, subpath server-safe
|
|
20
|
+
`@goerp/core/ui/shared/table-styles`); export DynamicIcon.
|
|
21
|
+
|
|
22
|
+
Verify: core tsc 0 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
|
|
23
|
+
golden vinhhoa tsc 20=20 (0 regression).
|
|
24
|
+
|
|
25
|
+
## 0.1.13 — Customizer: make radius, density & inset/floating actually apply
|
|
26
|
+
|
|
27
|
+
The Customizer exposed controls that stored a value but changed nothing on
|
|
28
|
+
screen. Wired them up at the source so every app using core gets them working:
|
|
29
|
+
|
|
30
|
+
- **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
|
|
31
|
+
`--radius` token from `settings.radius`; Tailwind's `@theme inline` derives
|
|
32
|
+
`rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 → square,
|
|
33
|
+
1rem → fully rounded). Previously nothing read `settings.radius`.
|
|
34
|
+
- **Density ("Mật độ") now applies.** `SettingsProvider` sets
|
|
35
|
+
`data-density="compact"` on `<html>`; `base.css` tightens the global
|
|
36
|
+
`--spacing` scale (0.25rem → 0.215rem) so every spacing utility packs in
|
|
37
|
+
compact mode. Previously `useDensity()` was read nowhere.
|
|
38
|
+
- **Inset / floating sidebar no longer renders a harsh frame.** Core's default
|
|
39
|
+
sidebar is deep navy, and shadcn's inset/floating paints the page frame with
|
|
40
|
+
that sidebar colour — reading as a broken-looking block. The frame is now the
|
|
41
|
+
soft `--muted` neutral, so the content floats as a card on a calm page
|
|
42
|
+
(adapts to dark mode; the rounded-xl + shadow core already applies stay).
|
|
43
|
+
- **Inset / floating collapsed rail no longer looks squeezed.** In icon mode the
|
|
44
|
+
floating/inset content box is `--sidebar-width-icon + 8px` (56px) but had
|
|
45
|
+
`px-2.5` padding, leaving the pill only 36px — narrower than the default 48px
|
|
46
|
+
rail. Reduced to `px-1` so the pill is a comfortable 48px with a 4px floating
|
|
47
|
+
gutter.
|
|
48
|
+
- **Inset / floating sidebar corners are softened.** The floating panel was
|
|
49
|
+
`rounded-lg` and the inset panel had NO radius (a sharp navy block whose
|
|
50
|
+
top-right corner met the header as a hard 90°). Both now use `rounded-xl`
|
|
51
|
+
(matching the inset content card) so the sidebar reads as a soft floating
|
|
52
|
+
panel — the header/sidebar junction is a gentle curve, not a rough corner.
|
|
53
|
+
The floating content area also rounds both left corners
|
|
54
|
+
(`rounded-l-xl`, clipping the sticky header + footer via overflow-hidden) so
|
|
55
|
+
its whole left edge hugs the floating sidebar with soft curves; the right
|
|
56
|
+
edge stays flush to the viewport (inset already rounds via its `rounded-xl`
|
|
57
|
+
card). Finally, the header's own BOTTOM-left corner is rounded in both
|
|
58
|
+
variants (base.css: `[data-variant=floating|inset] ~ main > header`), so its
|
|
59
|
+
divider curves into the left edge instead of ending in a hard inner corner.
|
|
60
|
+
And the content no longer touches the sidebar: inset drops its `ml-0` (so the
|
|
61
|
+
uniform `m-2` leaves a left gap) and floating gains `ml-2`, giving a small
|
|
62
|
+
gap between the sidebar and the content/header.
|
|
63
|
+
- **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
|
|
64
|
+
`AppSidebar` force-remapped `offcanvas → icon`, so the option collapsed to an
|
|
65
|
+
icon rail instead of hiding — identical to "Biểu tượng" and impossible to fully
|
|
66
|
+
hide the sidebar. The choice is now honoured as-is: offcanvas slides the
|
|
67
|
+
sidebar off-screen (reopen via the header toggle), icon → rail.
|
|
68
|
+
- **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
|
|
69
|
+
the sidebar expanded made the header collapse-toggle a no-op — a
|
|
70
|
+
conflicting/dead control. The picker now offers only offcanvas + icon, both of
|
|
71
|
+
which the toggle actually drives. (`none` remains a valid type for any code
|
|
72
|
+
that sets it directly; it's just no longer offered in the UI.)
|
|
73
|
+
- **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
|
|
74
|
+
Customizer.** The horizontal layout swaps the whole nav for a top menubar,
|
|
75
|
+
which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
|
|
76
|
+
a no-op — the same dead-control problem. The customizer now locks to the
|
|
77
|
+
vertical sidebar the apps are designed around (`defaultSettings.layout` stays
|
|
78
|
+
`vertical`; `HorizontalLayout` is untouched for code that sets it directly).
|
|
79
|
+
- **Non-collapsing sidebar (`collapsible="none"`) is now readable.** It rendered
|
|
80
|
+
`bg-sidebar text-sidebar-foreground` (the deep navy), but the group labels +
|
|
81
|
+
active item are `text-primary`, giving ~1.9:1 contrast (unreadable) on that
|
|
82
|
+
navy. A non-collapsing sidebar is permanently *expanded*, so it now uses the
|
|
83
|
+
expanded surface (`bg-background` / `text-foreground`), matching the rest of
|
|
84
|
+
the design (labels-on-light) — contrast jumps to ~9:1.
|
|
85
|
+
|
|
3
86
|
## 0.1.11 — print: force light colours on the print surface
|
|
4
87
|
|
|
5
88
|
- **Fix faded print output in dark mode.** Print pages render under the app's
|
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.
|
|
4
|
+
"version": "0.1.14",
|
|
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
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// Generic server-side CRUD engine (Prisma-backed) for @goerp/core.
|
|
3
|
+
//
|
|
4
|
+
// Core is schema-agnostic, so this is a FACTORY: the consuming app injects its
|
|
5
|
+
// own Prisma client + entity→model resolver. The engine itself (query building,
|
|
6
|
+
// filter/sort/search allowlist, relation connect/disconnect, Decimal serialize)
|
|
7
|
+
// lives here so every app stops re-implementing it.
|
|
8
|
+
//
|
|
9
|
+
// Usage (app side):
|
|
10
|
+
// import { createServerCrudService, getModelName } from "@goerp/core/crud/server";
|
|
11
|
+
// import { prisma } from "@/lib/prisma";
|
|
12
|
+
// const MODEL_MAP = { customers: "customer", "fee-schedules": "feeSchedule" };
|
|
13
|
+
// export const crudService = createServerCrudService({
|
|
14
|
+
// prisma,
|
|
15
|
+
// getModelName: (e) => getModelName(e, MODEL_MAP),
|
|
16
|
+
// });
|
|
17
|
+
|
|
18
|
+
import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
|
|
19
|
+
import { serializeDecimalFields } from "../utils/serialize";
|
|
20
|
+
|
|
21
|
+
type PrismaLike = Record<string, any>;
|
|
22
|
+
|
|
23
|
+
export interface ServerCrudService {
|
|
24
|
+
list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
|
|
25
|
+
getById(entity: string, id: string): Promise<any>;
|
|
26
|
+
create(entity: string, data: any, config?: EntityConfig): Promise<any>;
|
|
27
|
+
update(entity: string, id: string, data: any, config?: EntityConfig): Promise<any>;
|
|
28
|
+
delete(entity: string, id: string): Promise<any>;
|
|
29
|
+
deleteMany(entity: string, ids: string[]): Promise<any>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ServerCrudLogger {
|
|
33
|
+
warn(message: string, ...rest: unknown[]): void;
|
|
34
|
+
error(message: string, ...rest: unknown[]): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ServerCrudDeps {
|
|
38
|
+
/** The app's PrismaClient instance. */
|
|
39
|
+
prisma: PrismaLike;
|
|
40
|
+
/** Resolve a plural entity key → Prisma model name. Defaults to {@link getModelName}. */
|
|
41
|
+
getModelName?: (entity: string) => string;
|
|
42
|
+
/** Optional: enrich rows with createdByName/updatedByName. Schema-tolerant — inject
|
|
43
|
+
* {@link createAuditUserNameResolver} if your User model exposes a name field. */
|
|
44
|
+
resolveAuditNames?: (records: any[]) => Promise<any[]>;
|
|
45
|
+
logger?: ServerCrudLogger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_PAGE_SIZE = 200;
|
|
49
|
+
|
|
50
|
+
// Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
|
|
51
|
+
// irregular names pass a map: getModelName(entity, { "fee-schedules": "feeSchedule" }).
|
|
52
|
+
export function getModelName(entity: string, map?: Record<string, string>): string {
|
|
53
|
+
if (map && map[entity]) return map[entity];
|
|
54
|
+
return entity.replace(/s$/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Opt-in audit-name resolver. Overwrites createdByName/updatedByName from a User
|
|
58
|
+
// model. `nameField` handles schema drift (vinhhoa: "name", wu: "fullName").
|
|
59
|
+
export function createAuditUserNameResolver(opts: {
|
|
60
|
+
prisma: PrismaLike;
|
|
61
|
+
userModel?: string;
|
|
62
|
+
nameField?: string;
|
|
63
|
+
}): (records: any[]) => Promise<any[]> {
|
|
64
|
+
const { prisma, userModel = "user", nameField = "name" } = opts;
|
|
65
|
+
return async (records: any[]) => {
|
|
66
|
+
if (!records.length) return records;
|
|
67
|
+
const ids = new Set<string>();
|
|
68
|
+
for (const r of records) {
|
|
69
|
+
if (r?.createdBy) ids.add(r.createdBy);
|
|
70
|
+
if (r?.updatedBy) ids.add(r.updatedBy);
|
|
71
|
+
}
|
|
72
|
+
if (!ids.size) return records;
|
|
73
|
+
try {
|
|
74
|
+
const users = await prisma[userModel].findMany({
|
|
75
|
+
where: { id: { in: Array.from(ids) } },
|
|
76
|
+
select: { id: true, [nameField]: true },
|
|
77
|
+
});
|
|
78
|
+
const nameOf = new Map<string, string>(users.map((u: any) => [u.id, u[nameField] || u.id]));
|
|
79
|
+
return records.map((r) => ({
|
|
80
|
+
...r,
|
|
81
|
+
createdByName: r.createdBy ? nameOf.get(r.createdBy) ?? r.createdBy : null,
|
|
82
|
+
updatedByName: r.updatedBy ? nameOf.get(r.updatedBy) ?? r.updatedBy : null,
|
|
83
|
+
}));
|
|
84
|
+
} catch {
|
|
85
|
+
return records;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService {
|
|
91
|
+
const prisma = deps.prisma;
|
|
92
|
+
const resolveModel = deps.getModelName ?? ((e: string) => getModelName(e));
|
|
93
|
+
const resolveAuditNames = deps.resolveAuditNames ?? (async (r: any[]) => r);
|
|
94
|
+
const log: ServerCrudLogger = deps.logger ?? {
|
|
95
|
+
warn: (m, ...r) => console.warn(m, ...r),
|
|
96
|
+
error: (m, ...r) => console.error(m, ...r),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const model = (entity: string) => {
|
|
100
|
+
const name = resolveModel(entity);
|
|
101
|
+
const m = prisma[name];
|
|
102
|
+
if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
|
|
103
|
+
return m;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const filterValidFields = (data: any, config: EntityConfig) => {
|
|
107
|
+
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
108
|
+
valid.add("id");
|
|
109
|
+
const out: Record<string, unknown> = {};
|
|
110
|
+
for (const [k, v] of Object.entries(data)) {
|
|
111
|
+
if (valid.has(k)) out[k] = v;
|
|
112
|
+
else log.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const castFieldValues = (data: any, config: EntityConfig) => {
|
|
118
|
+
const out = { ...data };
|
|
119
|
+
for (const field of config.fields) {
|
|
120
|
+
const value = out[field.name];
|
|
121
|
+
if (value === undefined || value === null) continue;
|
|
122
|
+
if (field.type === "boolean" || field.type === "switch") {
|
|
123
|
+
let isTrue: boolean;
|
|
124
|
+
if (typeof value === "string") {
|
|
125
|
+
const lv = value.toLowerCase();
|
|
126
|
+
isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
|
|
127
|
+
} else isTrue = Boolean(value);
|
|
128
|
+
if (field.type === "switch" && field.options && field.options.length >= 2) {
|
|
129
|
+
out[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
|
|
130
|
+
} else out[field.name] = isTrue;
|
|
131
|
+
} else if (field.type === "number" || (field.type as string) === "integer") {
|
|
132
|
+
if (typeof value === "string") {
|
|
133
|
+
if (value.trim() === "") out[field.name] = null;
|
|
134
|
+
else {
|
|
135
|
+
const num = Number(value);
|
|
136
|
+
if (!isNaN(num)) out[field.name] = num;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const transformRelationFields = (data: any, mode: "create" | "update") => {
|
|
145
|
+
const out = { ...data };
|
|
146
|
+
const skip = new Set(["id", "createdBy", "updatedBy", "citizenId", "targetId"]);
|
|
147
|
+
for (const key of Object.keys(out)) {
|
|
148
|
+
if (skip.has(key)) continue;
|
|
149
|
+
if (key.endsWith("Id") && key.length > 2) {
|
|
150
|
+
const rel = key.slice(0, -2);
|
|
151
|
+
const value = out[key];
|
|
152
|
+
if (value && typeof value === "string" && value.trim() !== "") {
|
|
153
|
+
out[rel] = { connect: { id: value } };
|
|
154
|
+
delete out[key];
|
|
155
|
+
} else if (value === null || value === undefined || value === "") {
|
|
156
|
+
if (mode === "update") out[rel] = { disconnect: true };
|
|
157
|
+
delete out[key];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
async list(entity, config, params) {
|
|
166
|
+
const prismaModel = model(entity);
|
|
167
|
+
const { page = 1, pageSize = 10, search, sort, filters } = params;
|
|
168
|
+
const safePage = Math.max(1, Number(page) || 1);
|
|
169
|
+
const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
|
|
170
|
+
const skip = (safePage - 1) * safePageSize;
|
|
171
|
+
const take = safePageSize;
|
|
172
|
+
|
|
173
|
+
const allowedFields = new Set<string>([
|
|
174
|
+
...config.fields.map((f) => f.name),
|
|
175
|
+
"id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
|
|
176
|
+
]);
|
|
177
|
+
const allowedRelations = new Set<string>(config.include || []);
|
|
178
|
+
const isAllowed = (name: string) => {
|
|
179
|
+
if (!name) return false;
|
|
180
|
+
if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
|
|
181
|
+
return allowedFields.has(name);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const where: any = {};
|
|
185
|
+
if (search && search.trim()) {
|
|
186
|
+
const term = search.trim();
|
|
187
|
+
const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
|
|
188
|
+
if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
|
|
189
|
+
}
|
|
190
|
+
if (filters && filters.length) {
|
|
191
|
+
for (const filter of filters) {
|
|
192
|
+
const { name, value, operator } = filter as any;
|
|
193
|
+
if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
|
|
194
|
+
if (!isAllowed(name)) {
|
|
195
|
+
log.warn(`Ignoring filter on disallowed field "${name}" for entity "${entity}"`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
let target = where;
|
|
199
|
+
let key = name;
|
|
200
|
+
if (name.includes(".")) {
|
|
201
|
+
const parts = name.split(".");
|
|
202
|
+
key = parts.pop()!;
|
|
203
|
+
for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
|
|
204
|
+
}
|
|
205
|
+
const op = operator as string;
|
|
206
|
+
if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
|
|
207
|
+
else if (op === "in") target[key] = { in: value };
|
|
208
|
+
else if (op === "notIn") target[key] = { notIn: value };
|
|
209
|
+
else if (op === "eq") target[key] = value;
|
|
210
|
+
else if (op === "ne") target[key] = { not: value };
|
|
211
|
+
else if (op === "gt") target[key] = { gt: value };
|
|
212
|
+
else if (op === "gte") target[key] = { gte: value };
|
|
213
|
+
else if (op === "lt") target[key] = { lt: value };
|
|
214
|
+
else if (op === "lte") target[key] = { lte: value };
|
|
215
|
+
else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
|
|
216
|
+
else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
|
|
217
|
+
else if (op === "isNull") target[key] = null;
|
|
218
|
+
else if (op === "isNotNull") target[key] = { not: null };
|
|
219
|
+
else target[key] = value;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const orderBy: any = {};
|
|
224
|
+
const applySort = (field: string, direction: any) => {
|
|
225
|
+
if (field.includes(".")) {
|
|
226
|
+
const parts = field.split(".");
|
|
227
|
+
const leaf = parts.pop()!;
|
|
228
|
+
let t = orderBy;
|
|
229
|
+
for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
|
|
230
|
+
t[leaf] = direction;
|
|
231
|
+
} else orderBy[field] = direction;
|
|
232
|
+
};
|
|
233
|
+
if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
|
|
234
|
+
else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
|
|
235
|
+
else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
|
|
236
|
+
else orderBy[config.idField || "id"] = "desc";
|
|
237
|
+
|
|
238
|
+
const include: any = {};
|
|
239
|
+
if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
240
|
+
const includeOption = Object.keys(include).length ? { include } : {};
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const [total, data] = await Promise.all([
|
|
244
|
+
prismaModel.count({ where }),
|
|
245
|
+
prismaModel.findMany({ where, orderBy, skip, take, ...includeOption }),
|
|
246
|
+
]);
|
|
247
|
+
const serialized = serializeDecimalFields(data);
|
|
248
|
+
const resolved = await resolveAuditNames(serialized as any[]);
|
|
249
|
+
return { data: resolved, total, page: safePage, pageSize: safePageSize } as CrudResponse;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
log.error(`Error listing ${entity}:`, error);
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async getById(entity, id) {
|
|
257
|
+
const prismaModel = model(entity);
|
|
258
|
+
const result = await prismaModel.findUnique({ where: { id } });
|
|
259
|
+
if (!result) return null;
|
|
260
|
+
const serialized = serializeDecimalFields(result);
|
|
261
|
+
const [resolved] = await resolveAuditNames([serialized]);
|
|
262
|
+
return resolved;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async create(entity, data, config) {
|
|
266
|
+
const prismaModel = model(entity);
|
|
267
|
+
try {
|
|
268
|
+
let filtered = config ? filterValidFields(data, config) : data;
|
|
269
|
+
if (config) filtered = castFieldValues(filtered, config);
|
|
270
|
+
if (!filtered.id) filtered.id = crypto.randomUUID();
|
|
271
|
+
const prismaData = transformRelationFields(filtered, "create");
|
|
272
|
+
return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
|
|
273
|
+
} catch (error) {
|
|
274
|
+
log.error(`Error creating ${entity}:`, error);
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
async update(entity, id, data, config) {
|
|
280
|
+
const prismaModel = model(entity);
|
|
281
|
+
try {
|
|
282
|
+
let filtered = config ? filterValidFields(data, config) : data;
|
|
283
|
+
if (config) filtered = castFieldValues(filtered, config);
|
|
284
|
+
const prismaData = transformRelationFields(filtered, "update");
|
|
285
|
+
return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
|
|
286
|
+
} catch (error) {
|
|
287
|
+
log.error(`Error updating ${entity}:`, error);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
async delete(entity, id) {
|
|
293
|
+
const prismaModel = model(entity);
|
|
294
|
+
try {
|
|
295
|
+
return await prismaModel.delete({ where: { id } });
|
|
296
|
+
} catch (error) {
|
|
297
|
+
log.error(`Error deleting ${entity}:`, error);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
async deleteMany(entity, ids) {
|
|
303
|
+
const prismaModel = model(entity);
|
|
304
|
+
try {
|
|
305
|
+
return await prismaModel.deleteMany({ where: { id: { in: ids } } });
|
|
306
|
+
} catch (error) {
|
|
307
|
+
log.error(`Error deleting many ${entity}:`, error);
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/crud/server.ts
CHANGED
|
@@ -6,3 +6,21 @@ export {
|
|
|
6
6
|
getCrudPermissions,
|
|
7
7
|
mergePermissions,
|
|
8
8
|
} from './lib/permissions'
|
|
9
|
+
|
|
10
|
+
// Generic Prisma-backed CRUD engine + Next.js route-handler factories.
|
|
11
|
+
// Apps inject their PrismaClient + entity→model map; the engine lives in core.
|
|
12
|
+
export {
|
|
13
|
+
createServerCrudService,
|
|
14
|
+
getModelName,
|
|
15
|
+
createAuditUserNameResolver,
|
|
16
|
+
} from './server-service'
|
|
17
|
+
export type {
|
|
18
|
+
ServerCrudService,
|
|
19
|
+
ServerCrudDeps,
|
|
20
|
+
ServerCrudLogger,
|
|
21
|
+
} from './server-service'
|
|
22
|
+
export {
|
|
23
|
+
createCrudCollectionHandlers,
|
|
24
|
+
createCrudItemHandlers,
|
|
25
|
+
} from './crud-route-handlers'
|
|
26
|
+
export type { CrudHandlerDeps } from './crud-route-handlers'
|