@goplusvn/core 0.1.59 → 0.1.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/PLATFORM.md +18 -0
  2. package/bin/goerp-init.mjs +141 -0
  3. package/package.json +4 -4
  4. package/src/cron/db-cron-manager.ts +3 -3
  5. package/src/cron/index.ts +2 -2
  6. package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
  7. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  8. package/src/infrastructure/index.ts +4 -7
  9. package/src/ui/management/index.ts +3 -2
  10. package/templates/starter-app/.dockerignore +41 -0
  11. package/templates/starter-app/.env.example +25 -0
  12. package/templates/starter-app/AGENTS.md +52 -0
  13. package/templates/starter-app/Dockerfile +74 -0
  14. package/templates/starter-app/README.md +141 -0
  15. package/templates/starter-app/gitignore +9 -0
  16. package/templates/starter-app/next.config.mjs +50 -0
  17. package/templates/starter-app/package.json +55 -0
  18. package/templates/starter-app/postcss.config.mjs +5 -0
  19. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  20. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  21. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  22. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  23. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  24. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  25. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  26. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  27. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  28. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  29. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  30. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  31. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  32. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  33. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  34. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  35. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  36. package/templates/starter-app/prisma/seed.ts +127 -0
  37. package/templates/starter-app/prisma.config.ts +20 -0
  38. package/templates/starter-app/public/.gitkeep +2 -0
  39. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  40. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  41. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  42. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  43. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  53. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  54. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  55. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  56. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  57. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  58. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  59. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  60. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  61. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  62. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  63. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  64. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  66. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  67. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  68. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  69. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  70. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  71. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  72. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  73. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  74. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  75. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  76. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  77. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  78. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  79. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  80. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  81. package/templates/starter-app/src/app/globals.css +15 -0
  82. package/templates/starter-app/src/app/layout.tsx +16 -0
  83. package/templates/starter-app/src/app/page.tsx +8 -0
  84. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  85. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  86. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  87. package/templates/starter-app/src/configs/i18n.ts +12 -0
  88. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  89. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  90. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  91. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  92. package/templates/starter-app/src/configs/tenant.ts +18 -0
  93. package/templates/starter-app/src/data/dictionary.ts +8 -0
  94. package/templates/starter-app/src/data/navigations.ts +61 -0
  95. package/templates/starter-app/src/instrumentation.ts +105 -0
  96. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  97. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  98. package/templates/starter-app/src/lib/auth.ts +62 -0
  99. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  100. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  101. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  102. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  103. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  104. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  105. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  106. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  107. package/templates/starter-app/src/lib/logger.ts +30 -0
  108. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  109. package/templates/starter-app/src/lib/prisma.ts +80 -0
  110. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  111. package/templates/starter-app/src/lib/storage.ts +28 -0
  112. package/templates/starter-app/src/providers/index.tsx +54 -0
  113. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  114. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  115. package/templates/starter-app/src/proxy.ts +45 -0
  116. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  117. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  118. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  119. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  120. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  121. package/templates/starter-app/src/types/session.ts +29 -0
  122. package/templates/starter-app/tsconfig.json +47 -0
  123. package/templates/starter-app/vitest.config.ts +17 -0
  124. package/src/infrastructure/cron/index.ts +0 -6
  125. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  126. package/src/infrastructure/event-bus/index.ts +0 -2
  127. package/src/infrastructure/event-bus/types.ts +0 -22
  128. package/src/infrastructure/lock/decorators.ts +0 -67
  129. package/src/infrastructure/lock/index.ts +0 -2
  130. package/src/infrastructure/lock/lock-manager.ts +0 -33
  131. package/src/plugin/apps-registry.ts +0 -97
  132. package/src/plugin/index.ts +0 -5
  133. package/src/plugin/types.ts +0 -41
  134. package/src/ui/management/audit-log-page.tsx +0 -14
  135. package/src/ui/management/job-management.tsx +0 -308
  136. package/src/workflow/activity-timeline.tsx +0 -412
  137. package/src/workflow/approval-workflow.tsx +0 -31
  138. package/src/workflow/index.ts +0 -2
  139. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * rbac-sync — đồng bộ Permission Registry (src/configs/permissions) xuống DB.
3
+ * Chạy sau mỗi lần deploy có thêm/sửa resource. KHÔNG cần script vá thủ công.
4
+ *
5
+ * Idempotent và AN TOÀN trên production — chạy lại bao nhiêu lần cũng vậy:
6
+ * - Action: tạo nếu thiếu; không bao giờ sửa/xoá action sẵn có.
7
+ * - Resource: cập nhật name/group/mô tả/icon/thứ tự theo registry (registry
8
+ * sở hữu danh mục quyền); không bao giờ xoá.
9
+ * - Grant mặc định: CHỈ seed khi resource được TẠO MỚI ở lần chạy này —
10
+ * không bao giờ đè chỉnh sửa của quản trị viên.
11
+ * - Drift: liệt kê resource có trong DB mà chưa khai registry (chỉ cảnh báo).
12
+ *
13
+ * Dùng:
14
+ * pnpm rbac-sync # sync thật
15
+ * pnpm rbac-sync --dry-run # chỉ in kế hoạch
16
+ * pnpm rbac-sync --verbose # in cả danh sách drift
17
+ * pnpm rbac-sync --force-grants # áp lại grant mặc định cho MỌI resource
18
+ *
19
+ * `--force-grants` là lối thoát cho trường hợp resource đã tạo trước khi vai trò
20
+ * tồn tại (chạy rbac-sync trước `pnpm seed`): resource có đủ mà không ai được
21
+ * quyền gì, và lần chạy sau không còn "tạo mới" nên chẳng grant lại. Cờ này chỉ
22
+ * THÊM quyền (upsert), không bao giờ thu hồi — nhưng nó cũng dựng lại quyền mà
23
+ * quản trị viên đã cố tình gỡ, nên đừng đưa vào quy trình deploy.
24
+ */
25
+
26
+ import { randomUUID } from "node:crypto"
27
+
28
+ import { PrismaPg } from "@prisma/adapter-pg"
29
+ import { PrismaClient } from "@prisma/client"
30
+ import "dotenv/config"
31
+
32
+ import { permissionRegistry } from "../src/configs/permissions"
33
+
34
+ const DRY_RUN = process.argv.includes("--dry-run")
35
+ const VERBOSE = process.argv.includes("--verbose")
36
+ const FORCE_GRANTS = process.argv.includes("--force-grants")
37
+
38
+ /** Action ai cũng hiểu — khỏi bắt mỗi app khai lại trong customActions. */
39
+ const STANDARD_ACTIONS: Record<string, string> = {
40
+ view: "Xem",
41
+ create: "Thêm",
42
+ update: "Sửa",
43
+ delete: "Xóa",
44
+ export: "Xuất dữ liệu",
45
+ import: "Nhập dữ liệu",
46
+ }
47
+
48
+ const connectionString = process.env.DATABASE_URL
49
+ if (!connectionString) throw new Error("DATABASE_URL chưa được set (xem .env.example)")
50
+ const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) })
51
+
52
+ async function main() {
53
+ const now = new Date()
54
+ const resourceCount = permissionRegistry.reduce((n, f) => n + f.resources.length, 0)
55
+ console.log(
56
+ `→ rbac-sync: ${permissionRegistry.length} phân hệ, ${resourceCount} resource${DRY_RUN ? " [DRY RUN]" : ""}`,
57
+ )
58
+
59
+ // ── 1. Action: tạo nếu thiếu ───────────────────────────────────────────
60
+ const declaredActions = new Map<string, { name: string; description?: string }>()
61
+ for (const feature of permissionRegistry) {
62
+ for (const a of feature.customActions ?? []) {
63
+ declaredActions.set(a.code, { name: a.name, description: a.description })
64
+ }
65
+ }
66
+ const usedActionCodes = new Set(
67
+ permissionRegistry.flatMap((f) => f.resources.flatMap((r) => [...r.actions])),
68
+ )
69
+
70
+ const existingActionCodes = new Set(
71
+ (await prisma.action.findMany({ select: { code: true } })).map((a) => a.code),
72
+ )
73
+
74
+ for (const code of usedActionCodes) {
75
+ if (existingActionCodes.has(code)) continue
76
+ const decl = declaredActions.get(code) ?? {
77
+ name: STANDARD_ACTIONS[code],
78
+ description: undefined,
79
+ }
80
+ if (!decl.name) {
81
+ // Dừng hẳn thay vì tạo action rác: sai chính tả một action code sẽ khiến
82
+ // quyền không bao giờ khớp và nút bấm ẩn vĩnh viễn.
83
+ throw new Error(
84
+ `Action "${code}" được dùng nhưng không nằm trong bộ chuẩn và chưa khai ở customActions của phân hệ nào.`,
85
+ )
86
+ }
87
+ console.log(` + action: ${code} (${decl.name})`)
88
+ if (!DRY_RUN) {
89
+ await prisma.action.create({
90
+ data: { code, name: decl.name, description: decl.description ?? "" },
91
+ })
92
+ }
93
+ }
94
+
95
+ // ── 2. Resource: upsert metadata + config.actions ──────────────────────
96
+ // config.actions = danh sách action HIỆN trên ma trận phân quyền của core.
97
+ // Registry sở hữu khóa `actions`; các khóa khác admin thêm vào được giữ nguyên.
98
+ const createdResourceCodes = new Set<string>()
99
+ for (const feature of permissionRegistry) {
100
+ for (const r of feature.resources) {
101
+ const existing = await prisma.resource.findUnique({
102
+ where: { code: r.code },
103
+ select: { code: true, config: true },
104
+ })
105
+
106
+ let config: Record<string, unknown> = {}
107
+ if (existing?.config) {
108
+ try {
109
+ config = JSON.parse(existing.config)
110
+ } catch {
111
+ console.warn(` ! config của ${r.code} không phải JSON — ghi đè`)
112
+ }
113
+ }
114
+ // Lớp tùy chỉnh của admin (thêm/tắt action trên UI) được BẢO TOÀN:
115
+ // hiệu lực = registry ∪ customActions − disabledActions
116
+ const custom = Array.isArray(config.customActions) ? (config.customActions as string[]) : []
117
+ const disabled = Array.isArray(config.disabledActions)
118
+ ? (config.disabledActions as string[])
119
+ : []
120
+ config.actions = [...new Set([...r.actions, ...custom])].filter(
121
+ (a) => !disabled.includes(a),
122
+ )
123
+
124
+ if (!existing) {
125
+ createdResourceCodes.add(r.code)
126
+ console.log(` + resource: ${r.code} (${r.name}) [${feature.feature}]`)
127
+ } else if (VERBOSE) {
128
+ console.log(` = resource: ${r.code} — cập nhật metadata`)
129
+ }
130
+
131
+ if (!DRY_RUN) {
132
+ const data = {
133
+ name: r.name,
134
+ group: r.group,
135
+ description: r.description ?? feature.description ?? null,
136
+ icon: r.icon ?? null,
137
+ order: r.order ?? null,
138
+ config: JSON.stringify(config),
139
+ }
140
+ await prisma.resource.upsert({
141
+ where: { code: r.code },
142
+ create: { code: r.code, ...data },
143
+ update: data,
144
+ })
145
+ }
146
+ }
147
+ }
148
+
149
+ // ── 3. Grant mặc định — CHỈ cho resource vừa tạo (trừ khi --force-grants) ──
150
+ const wantsGrants = (code: string) => FORCE_GRANTS || createdResourceCodes.has(code)
151
+ let skippedGrants = 0
152
+
153
+ const neededRoleCodes = new Set<string>()
154
+ for (const feature of permissionRegistry) {
155
+ for (const r of feature.resources) {
156
+ if (!wantsGrants(r.code)) continue
157
+ for (const roleCode of Object.keys(r.defaultGrants ?? {})) neededRoleCodes.add(roleCode)
158
+ }
159
+ }
160
+ const presentRoles = new Set(
161
+ neededRoleCodes.size
162
+ ? (
163
+ await prisma.role.findMany({
164
+ where: { code: { in: [...neededRoleCodes] } },
165
+ select: { code: true },
166
+ })
167
+ ).map((r) => r.code)
168
+ : [],
169
+ )
170
+
171
+ for (const feature of permissionRegistry) {
172
+ for (const r of feature.resources) {
173
+ if (!wantsGrants(r.code)) continue
174
+ for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
175
+ if (!presentRoles.has(roleCode)) {
176
+ skippedGrants++
177
+ console.warn(` ! vai trò "${roleCode}" chưa tồn tại — bỏ qua grant cho ${r.code}`)
178
+ continue
179
+ }
180
+ for (const actionCode of grant === "*" ? r.actions : grant) {
181
+ if (VERBOSE || !FORCE_GRANTS) console.log(` ✓ grant: ${roleCode} → ${r.code}:${actionCode}`)
182
+ if (!DRY_RUN) {
183
+ await prisma.rolePermission.upsert({
184
+ where: {
185
+ roleCode_resourceCode_actionCode: {
186
+ roleCode,
187
+ resourceCode: r.code,
188
+ actionCode,
189
+ },
190
+ },
191
+ create: { id: randomUUID(), roleCode, resourceCode: r.code, actionCode },
192
+ update: {},
193
+ })
194
+ }
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ // ── 4. Drift (chỉ cảnh báo) ────────────────────────────────────────────
201
+ const registryCodes = new Set(
202
+ permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
203
+ )
204
+ const legacy = (
205
+ await prisma.resource.findMany({ where: { status: "active" }, select: { code: true } })
206
+ )
207
+ .map((r) => r.code)
208
+ .filter((code) => !registryCodes.has(code))
209
+ .sort()
210
+
211
+ if (legacy.length) {
212
+ console.log(`→ Drift: ${legacy.length} resource trong DB chưa khai registry`)
213
+ if (VERBOSE) console.log(` ${legacy.join(", ")}`)
214
+ }
215
+
216
+ console.log(
217
+ `→ Xong: ${createdResourceCodes.size} resource mới, ${registryCodes.size - createdResourceCodes.size} resource cập nhật.`,
218
+ )
219
+
220
+ // Bỏ qua grant = resource đã tạo nhưng KHÔNG ai có quyền, và lần chạy sau
221
+ // resource không còn "mới" nên sẽ im lặng bỏ qua mãi. Nói thẳng cách chữa.
222
+ if (skippedGrants) {
223
+ console.warn(
224
+ `\n⚠ ${skippedGrants} grant bị bỏ vì vai trò chưa tồn tại. Chạy \`pnpm seed\` để tạo vai trò,` +
225
+ ` rồi \`pnpm rbac-sync --force-grants\` để áp lại — nếu không sẽ không ai vào được màn hình nào.`,
226
+ )
227
+ }
228
+ }
229
+
230
+ main()
231
+ .catch((e) => {
232
+ console.error(e)
233
+ process.exit(1)
234
+ })
235
+ .finally(() => prisma.$disconnect())
@@ -0,0 +1,151 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs"
2
+ import { join, relative } from "node:path"
3
+
4
+ import { describe, expect, it } from "vitest"
5
+
6
+ import { permissionRegistry } from "@/configs/permissions"
7
+ import { navigations } from "@/data/navigations"
8
+
9
+ /**
10
+ * RATCHET — hàng rào kiến trúc. Mỗi kiểm tra ở đây tương ứng một lỗi im lặng
11
+ * đã từng làm mất thời gian trong app thật: quyền khai sai thì nút biến mất
12
+ * không báo lỗi, route quên gác thì thành lỗ hổng, hai Prisma client thì rò
13
+ * connection pool. Thêm quy tắc mới khi bạn gặp lỗi loại đó; đừng nới lỏng.
14
+ */
15
+
16
+ const SRC = join(__dirname, "..")
17
+
18
+ function walk(dir: string, out: string[] = []): string[] {
19
+ for (const entry of readdirSync(dir)) {
20
+ const full = join(dir, entry)
21
+ if (statSync(full).isDirectory()) {
22
+ if (entry === "node_modules" || entry === "__tests__") continue
23
+ walk(full, out)
24
+ } else if (/\.(ts|tsx)$/.test(entry)) {
25
+ out.push(full)
26
+ }
27
+ }
28
+ return out
29
+ }
30
+
31
+ const allFiles = walk(SRC)
32
+ const read = (f: string) => readFileSync(f, "utf8")
33
+ const rel = (f: string) => relative(SRC, f)
34
+
35
+ /** Bộ action ai cũng hiểu — trùng danh sách trong scripts/rbac-sync.ts. */
36
+ const STANDARD_ACTIONS = ["view", "create", "update", "delete", "export", "import"]
37
+
38
+ describe("permission registry", () => {
39
+ const declaredCustom = new Set(
40
+ permissionRegistry.flatMap((f) => (f.customActions ?? []).map((a) => a.code)),
41
+ )
42
+
43
+ it("mọi action được dùng đều là action chuẩn hoặc đã khai customActions", () => {
44
+ const unknown: string[] = []
45
+ for (const feature of permissionRegistry) {
46
+ for (const r of feature.resources) {
47
+ for (const a of r.actions) {
48
+ if (!STANDARD_ACTIONS.includes(a) && !declaredCustom.has(a)) {
49
+ unknown.push(`${r.code}:${a}`)
50
+ }
51
+ }
52
+ }
53
+ }
54
+ // rbac-sync ném lỗi khi gặp trường hợp này — bắt sớm ở test rẻ hơn nhiều
55
+ // so với phát hiện lúc deploy.
56
+ expect(unknown).toEqual([])
57
+ })
58
+
59
+ it("defaultGrants chỉ trỏ tới action đã khai trên chính resource đó", () => {
60
+ const bad: string[] = []
61
+ for (const feature of permissionRegistry) {
62
+ for (const r of feature.resources) {
63
+ for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
64
+ if (grant === "*") continue
65
+ for (const a of grant) {
66
+ if (!r.actions.includes(a)) bad.push(`${roleCode} → ${r.code}:${a}`)
67
+ }
68
+ }
69
+ }
70
+ }
71
+ expect(bad).toEqual([])
72
+ })
73
+
74
+ it("mã resource dùng kebab-case (khớp chuỗi trong checkPermission và URL)", () => {
75
+ const bad = permissionRegistry
76
+ .flatMap((f) => f.resources.map((r) => r.code))
77
+ .filter((code) => !/^[a-z][a-z0-9-]*$/.test(code))
78
+ expect(bad).toEqual([])
79
+ })
80
+
81
+ it("mọi mục navigation có `resource` đều đã khai trong registry", () => {
82
+ const declared = new Set(
83
+ permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
84
+ )
85
+ const missing: string[] = []
86
+ for (const group of navigations) {
87
+ for (const item of group.items ?? []) {
88
+ const resource = (item as { resource?: string }).resource
89
+ if (resource && !declared.has(resource)) missing.push(resource)
90
+ }
91
+ }
92
+ // Mục nav trỏ tới resource chưa khai sẽ ẨN VĨNH VIỄN với mọi người dùng
93
+ // (kể cả admin) mà không có thông báo nào.
94
+ expect(missing).toEqual([])
95
+ })
96
+ })
97
+
98
+ describe("ranh giới kiến trúc", () => {
99
+ it("không còn dấu vết NextAuth (app đã sang Better Auth)", () => {
100
+ const offenders = allFiles.filter((f) => /from ["']next-auth/.test(read(f)))
101
+ expect(offenders.map(rel)).toEqual([])
102
+ })
103
+
104
+ it("chỉ src/lib/prisma.ts được tạo PrismaClient", () => {
105
+ const offenders = allFiles.filter(
106
+ (f) => /new PrismaClient\(/.test(read(f)) && rel(f) !== "lib/prisma.ts",
107
+ )
108
+ // Client thứ hai = pool kết nối thứ hai + bỏ qua audit extension.
109
+ expect(offenders.map(rel)).toEqual([])
110
+ })
111
+
112
+ it("route API đều đi qua getSession hoặc handler có gác quyền của core", () => {
113
+ const routes = allFiles.filter((f) => /app\/api\/.*route\.ts$/.test(rel(f)))
114
+ expect(routes.length).toBeGreaterThan(0)
115
+
116
+ // better-auth tự lo xác thực cho chính nó (đăng nhập thì làm gì có phiên).
117
+ const exempt = ["app/api/better-auth"]
118
+ const offenders = routes.filter((f) => {
119
+ if (exempt.some((p) => rel(f).startsWith(p))) return false
120
+ const src = read(f)
121
+ // apiHandler( = cổng của app — chấp cả dạng có generic
122
+ // `apiHandler<{ key: string[] }>(` ở route catch-all; create*Handlers( =
123
+ // factory sẵn của core (rbac/crud) vốn đã tự gác session + quyền bên trong.
124
+ return !/apiHandler[<(]|create\w*Handlers\(|getSession/.test(src)
125
+ })
126
+ expect(offenders.map(rel)).toEqual([])
127
+ })
128
+
129
+ it("resource mà route gác đều đã khai trong registry", () => {
130
+ const declared = new Set(
131
+ permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
132
+ )
133
+ const missing: string[] = []
134
+ for (const file of allFiles.filter((f) => /app\/api\/.*route\.ts$/.test(rel(f)))) {
135
+ for (const [, code] of read(file).matchAll(/resource:\s*["']([^"']+)["']/g)) {
136
+ if (!declared.has(code)) missing.push(`${rel(file)} → ${code}`)
137
+ }
138
+ }
139
+ // assertResourceDeclared cũng bắt lỗi này, nhưng chỉ khi route được load.
140
+ // Test quét tĩnh nên thấy cả route chưa ai gọi tới.
141
+ expect(missing).toEqual([])
142
+ })
143
+
144
+ it("cấu hình quyền là dữ liệu thuần — không kéo prisma/next/react vào", () => {
145
+ const offenders = allFiles
146
+ .filter((f) => rel(f).startsWith("configs/permissions"))
147
+ .filter((f) => /from ["'](@\/lib\/prisma|next|react|@prisma)/.test(read(f)))
148
+ // scripts/rbac-sync.ts và test đọc thẳng các file này qua tsx.
149
+ expect(offenders.map(rel)).toEqual([])
150
+ })
151
+ })
@@ -0,0 +1,24 @@
1
+ import { SystemAuditPage } from "@goerp/core/system/pages/system-audit-page"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ import { requirePageAccess } from "@/lib/page-guard"
6
+
7
+ export const metadata: Metadata = { title: "Nhật ký hoạt động" }
8
+
9
+ export default async function AuditRoute({
10
+ params,
11
+ }: {
12
+ params: Promise<{ lang: string }>
13
+ }) {
14
+ const { lang } = await params
15
+ await requirePageAccess(lang, "audit-log")
16
+
17
+ // Bảng nhật ký tự cuộn bên trong — cho nó chiều cao cố định thay vì để cả
18
+ // trang dài ra theo số dòng.
19
+ return (
20
+ <div className="h-[calc(100vh-120px)]">
21
+ <SystemAuditPage />
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,21 @@
1
+ import { ErrorLogsPage } from "@goerp/core/system/pages/error-logs-page"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ import { requirePageAccess } from "@/lib/page-guard"
6
+
7
+ export const metadata: Metadata = { title: "Nhật ký lỗi" }
8
+
9
+ /** `?search=<errorId>` để deep-link thẳng tới một lỗi từ thông báo/dashboard. */
10
+ export default async function ErrorLogsRoute({
11
+ params,
12
+ searchParams,
13
+ }: {
14
+ params: Promise<{ lang: string }>
15
+ searchParams: Promise<{ search?: string }>
16
+ }) {
17
+ const [{ lang }, sp] = await Promise.all([params, searchParams])
18
+ await requirePageAccess(lang, "error-log")
19
+
20
+ return <ErrorLogsPage initialSearch={sp.search} />
21
+ }
@@ -0,0 +1,18 @@
1
+ import { SystemJobsPage } from "@goerp/core/system/pages/system-jobs-page"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ import { requirePageAccess } from "@/lib/page-guard"
6
+
7
+ export const metadata: Metadata = { title: "Tác vụ định kỳ" }
8
+
9
+ export default async function JobsRoute({
10
+ params,
11
+ }: {
12
+ params: Promise<{ lang: string }>
13
+ }) {
14
+ const { lang } = await params
15
+ await requirePageAccess(lang, "system-job")
16
+
17
+ return <SystemJobsPage />
18
+ }
@@ -0,0 +1,22 @@
1
+ import { SystemSettingsPage } from "@goerp/core/system/pages/system-settings-page"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ import { requirePageAccess } from "@/lib/page-guard"
6
+
7
+ export const metadata: Metadata = { title: "Cấu hình hệ thống" }
8
+
9
+ /**
10
+ * Trang cài đặt = màn hình sẵn của core + các route dưới
11
+ * /api/admin/system/settings/* mà app cung cấp. Không có mã giao diện riêng.
12
+ */
13
+ export default async function SettingsPage({
14
+ params,
15
+ }: {
16
+ params: Promise<{ lang: string }>
17
+ }) {
18
+ const { lang } = await params
19
+ await requirePageAccess(lang, "system-setting")
20
+
21
+ return <SystemSettingsPage lang={lang} />
22
+ }
@@ -0,0 +1,41 @@
1
+ import { EntityCrudPage } from "@goerp/core/crud"
2
+
3
+ import type { LocaleType } from "@goerp/core/types"
4
+
5
+ import { getEntityConfig } from "@/configs/entities"
6
+ import { dictionary } from "@/data/dictionary"
7
+ import { getSession } from "@/lib/auth"
8
+
9
+ export const dynamic = "force-dynamic"
10
+
11
+ // Generic CRUD page — one file serves EVERY entity in the registry. The core
12
+ // engine renders table + form + filters + import/export from the EntityConfig.
13
+ // Add an entity: register it in src/configs/entities and link /crud/<key> in nav.
14
+ export default async function CrudEntityPage({
15
+ params,
16
+ }: {
17
+ params: Promise<{ lang: string; entity: string }>
18
+ }) {
19
+ const { lang, entity } = await params
20
+ const config = getEntityConfig(entity)
21
+
22
+ if (!config) {
23
+ return (
24
+ <div className="p-6 text-sm text-muted-foreground">
25
+ Không tìm thấy entity: <code className="rounded bg-muted px-1">{entity}</code>
26
+ </div>
27
+ )
28
+ }
29
+
30
+ const session = await getSession()
31
+
32
+ return (
33
+ <EntityCrudPage
34
+ entity={entity}
35
+ lang={lang as LocaleType}
36
+ config={config}
37
+ session={session!}
38
+ dictionary={dictionary}
39
+ />
40
+ )
41
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
4
+ import { dictionary } from "@/data/dictionary"
5
+ import { navigations } from "@/data/navigations"
6
+
7
+ export default function MainAreaLayout({ children }: { children: ReactNode }) {
8
+ return (
9
+ <MainLayoutWrapper dictionary={dictionary} navigation={navigations}>
10
+ {children}
11
+ </MainLayoutWrapper>
12
+ )
13
+ }
@@ -0,0 +1,13 @@
1
+ import { NotificationsInbox } from "@goerp/core/notification/ui"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ export const metadata: Metadata = { title: "Thông báo" }
6
+
7
+ /**
8
+ * Hộp thư của CHÍNH người đang đăng nhập — không cần quyền RBAC, API đã tự
9
+ * lọc theo `session.user.id`. Chuông trên thanh tiêu đề trỏ về trang này.
10
+ */
11
+ export default function NotificationsPage() {
12
+ return <NotificationsInbox />
13
+ }
@@ -0,0 +1,130 @@
1
+ import { StatBar } from "@goerp/core/ui"
2
+ import { ArrowRight, Building2, ShieldCheck, Sparkles } from "lucide-react"
3
+ import Link from "next/link"
4
+
5
+ import { db } from "@/lib/prisma"
6
+
7
+ export const dynamic = "force-dynamic"
8
+
9
+ // Sample ERP dashboard. Server component: query counts, hand StatBar plain data.
10
+ // Replace the KPIs + quick actions with your domain's once you add entities.
11
+ export default async function HomePage() {
12
+ const [departmentCount, activeDepartments, userCount] = await Promise.all([
13
+ db.department.count(),
14
+ db.department.count({ where: { status: "active" } }),
15
+ db.user.count(),
16
+ ])
17
+
18
+ return (
19
+ <div className="flex h-full flex-col gap-4 p-4 md:p-6">
20
+ <div className="space-y-1">
21
+ <h1 className="text-2xl font-bold tracking-tight text-foreground">
22
+ Bảng điều khiển
23
+ </h1>
24
+ <p className="text-sm text-muted-foreground">
25
+ App mẫu chạy trên{" "}
26
+ <code className="rounded bg-muted px-1">@goerp/core</code> — auth, RBAC,
27
+ CRUD engine và design system đều từ core.
28
+ </p>
29
+ </div>
30
+
31
+ <StatBar
32
+ items={[
33
+ {
34
+ id: "departments",
35
+ label: "Phòng ban",
36
+ value: departmentCount,
37
+ iconName: "Building2",
38
+ colorTheme: "dark",
39
+ isHighlighted: true,
40
+ },
41
+ {
42
+ id: "active",
43
+ label: "Đang hoạt động",
44
+ value: activeDepartments,
45
+ iconName: "CheckCircle2",
46
+ colorTheme: "emerald",
47
+ },
48
+ {
49
+ id: "users",
50
+ label: "Người dùng",
51
+ value: userCount,
52
+ iconName: "Users",
53
+ colorTheme: "blue",
54
+ },
55
+ ]}
56
+ />
57
+
58
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
59
+ <QuickCard
60
+ href="/crud/departments"
61
+ icon={<Building2 className="size-5" />}
62
+ title="Phòng ban"
63
+ desc="Xem, thêm, sửa cơ cấu tổ chức — bảng/biểu mẫu/lọc/nhập-xuất tự sinh từ EntityConfig."
64
+ />
65
+ <InfoCard
66
+ icon={<Sparkles className="size-5" />}
67
+ title="Thêm entity mới"
68
+ desc="Khai báo EntityConfig, đăng ký trong src/configs/entities, thêm 1 dòng nav /crud/<key>. Không viết engine."
69
+ />
70
+ <InfoCard
71
+ icon={<ShieldCheck className="size-5" />}
72
+ title="Phân quyền"
73
+ desc="Đăng nhập thật qua bảng User (admin/admin123). Role 'admin' = full quyền; RBAC chi tiết thêm bảng RolePermission."
74
+ />
75
+ </div>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ function QuickCard({
81
+ href,
82
+ icon,
83
+ title,
84
+ desc,
85
+ }: {
86
+ href: string
87
+ icon: React.ReactNode
88
+ title: string
89
+ desc: string
90
+ }) {
91
+ return (
92
+ <Link
93
+ href={href}
94
+ className="group flex flex-col gap-3 rounded-lg border border-border bg-card p-5 transition-colors hover:border-primary/40 hover:bg-accent"
95
+ >
96
+ <div className="flex items-center justify-between">
97
+ <span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
98
+ {icon}
99
+ </span>
100
+ <ArrowRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
101
+ </div>
102
+ <div>
103
+ <div className="font-semibold text-foreground">{title}</div>
104
+ <p className="mt-1 text-sm text-muted-foreground">{desc}</p>
105
+ </div>
106
+ </Link>
107
+ )
108
+ }
109
+
110
+ function InfoCard({
111
+ icon,
112
+ title,
113
+ desc,
114
+ }: {
115
+ icon: React.ReactNode
116
+ title: string
117
+ desc: string
118
+ }) {
119
+ return (
120
+ <div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-5">
121
+ <span className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
122
+ {icon}
123
+ </span>
124
+ <div>
125
+ <div className="font-semibold text-foreground">{title}</div>
126
+ <p className="mt-1 text-sm text-muted-foreground">{desc}</p>
127
+ </div>
128
+ </div>
129
+ )
130
+ }