@goplusvn/core 0.1.59 → 0.1.61

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 (142) 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/crud/lib/mutation-builder.ts +105 -0
  8. package/src/crud/lib/query-builder.ts +119 -0
  9. package/src/crud/server-service.ts +35 -163
  10. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  11. package/src/infrastructure/index.ts +4 -7
  12. package/src/ui/management/index.ts +3 -2
  13. package/templates/starter-app/.dockerignore +41 -0
  14. package/templates/starter-app/.env.example +25 -0
  15. package/templates/starter-app/AGENTS.md +52 -0
  16. package/templates/starter-app/Dockerfile +74 -0
  17. package/templates/starter-app/README.md +141 -0
  18. package/templates/starter-app/gitignore +9 -0
  19. package/templates/starter-app/next.config.mjs +50 -0
  20. package/templates/starter-app/package.json +55 -0
  21. package/templates/starter-app/postcss.config.mjs +5 -0
  22. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  23. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  24. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  25. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  26. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  27. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  28. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  29. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  30. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  31. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  32. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  33. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  34. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  35. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  36. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  37. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  38. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  39. package/templates/starter-app/prisma/seed.ts +127 -0
  40. package/templates/starter-app/prisma.config.ts +20 -0
  41. package/templates/starter-app/public/.gitkeep +2 -0
  42. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  43. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  44. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  45. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  46. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  54. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  55. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  56. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  57. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  58. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  59. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  60. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  61. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  62. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  63. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  64. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  66. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  67. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  68. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  69. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  70. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  71. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  72. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  73. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  74. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  75. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  76. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  77. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  78. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  79. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  80. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  81. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  82. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  83. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  84. package/templates/starter-app/src/app/globals.css +15 -0
  85. package/templates/starter-app/src/app/layout.tsx +16 -0
  86. package/templates/starter-app/src/app/page.tsx +8 -0
  87. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  88. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  89. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  90. package/templates/starter-app/src/configs/i18n.ts +12 -0
  91. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  92. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  93. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  94. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  95. package/templates/starter-app/src/configs/tenant.ts +18 -0
  96. package/templates/starter-app/src/data/dictionary.ts +8 -0
  97. package/templates/starter-app/src/data/navigations.ts +61 -0
  98. package/templates/starter-app/src/instrumentation.ts +105 -0
  99. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  100. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  101. package/templates/starter-app/src/lib/auth.ts +62 -0
  102. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  103. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  104. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  105. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  106. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  107. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  108. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  109. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  110. package/templates/starter-app/src/lib/logger.ts +30 -0
  111. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  112. package/templates/starter-app/src/lib/prisma.ts +80 -0
  113. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  114. package/templates/starter-app/src/lib/storage.ts +28 -0
  115. package/templates/starter-app/src/providers/index.tsx +54 -0
  116. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  117. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  118. package/templates/starter-app/src/proxy.ts +45 -0
  119. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  120. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  121. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  122. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  123. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  124. package/templates/starter-app/src/types/session.ts +29 -0
  125. package/templates/starter-app/tsconfig.json +47 -0
  126. package/templates/starter-app/vitest.config.ts +17 -0
  127. package/src/infrastructure/cron/index.ts +0 -6
  128. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  129. package/src/infrastructure/event-bus/index.ts +0 -2
  130. package/src/infrastructure/event-bus/types.ts +0 -22
  131. package/src/infrastructure/lock/decorators.ts +0 -67
  132. package/src/infrastructure/lock/index.ts +0 -2
  133. package/src/infrastructure/lock/lock-manager.ts +0 -33
  134. package/src/plugin/apps-registry.ts +0 -97
  135. package/src/plugin/index.ts +0 -5
  136. package/src/plugin/types.ts +0 -41
  137. package/src/ui/management/audit-log-page.tsx +0 -14
  138. package/src/ui/management/job-management.tsx +0 -308
  139. package/src/workflow/activity-timeline.tsx +0 -412
  140. package/src/workflow/approval-workflow.tsx +0 -31
  141. package/src/workflow/index.ts +0 -2
  142. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
@@ -15,7 +15,8 @@
15
15
  // getModelName: (e) => getModelName(e, MODEL_MAP),
16
16
  // });
17
17
 
18
- import { compileFilterTree } from "./lib/filter-tree";
18
+ import { buildListQuery } from "./lib/query-builder";
19
+ import { prepareMutationData } from "./lib/mutation-builder";
19
20
  import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
20
21
  import { serializeDecimalFields } from "../utils/serialize";
21
22
 
@@ -136,163 +137,27 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
136
137
  return m;
137
138
  };
138
139
 
139
- const filterValidFields = (data: any, config: EntityConfig) => {
140
- const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
141
- for (const f of systemFields) valid.add(f);
142
- const out: Record<string, unknown> = {};
143
- for (const [k, v] of Object.entries(data)) {
144
- if (valid.has(k)) out[k] = v;
145
- else log.warn(`Filtering out invalid field "${k}" for entity "${config.name}"`);
146
- }
147
- return out;
148
- };
149
140
 
150
- const castFieldValues = (data: any, config: EntityConfig) => {
151
- const out = { ...data };
152
- for (const field of config.fields) {
153
- const value = out[field.name];
154
- if (value === undefined || value === null) continue;
155
- if (field.type === "boolean" || field.type === "switch") {
156
- let isTrue: boolean;
157
- if (typeof value === "string") {
158
- const lv = value.toLowerCase();
159
- isTrue = lv === "true" || lv === "active" || value === "1" || value === "on";
160
- } else isTrue = Boolean(value);
161
- if (field.type === "switch" && field.options && field.options.length >= 2) {
162
- out[field.name] = isTrue ? (field.options[0] as any).value : (field.options[1] as any).value;
163
- } else out[field.name] = isTrue;
164
- } else if (field.type === "number" || (field.type as string) === "integer") {
165
- if (typeof value === "string") {
166
- if (value.trim() === "") out[field.name] = null;
167
- else {
168
- const num = Number(value);
169
- if (!isNaN(num)) out[field.name] = num;
170
- }
171
- }
172
- }
173
- }
174
- return out;
175
- };
176
-
177
- const transformRelationFields = (data: any, mode: "create" | "update") => {
178
- const out = { ...data };
179
- // citizenId/targetId là legacy-default từ consumer đầu tiên — app mới khai
180
- // cột scalar *Id của mình qua deps.relationFieldSkip thay vì sửa core.
181
- const skip = new Set([
182
- "id",
183
- "createdBy",
184
- "updatedBy",
185
- "citizenId",
186
- "targetId",
187
- ...(deps.relationFieldSkip ?? []),
188
- ]);
189
- for (const key of Object.keys(out)) {
190
- if (skip.has(key)) continue;
191
- if (key.endsWith("Id") && key.length > 2) {
192
- const rel = key.slice(0, -2);
193
- const value = out[key];
194
- if (value && typeof value === "string" && value.trim() !== "") {
195
- out[rel] = { connect: { id: value } };
196
- delete out[key];
197
- } else if (value === null || value === undefined || value === "") {
198
- if (mode === "update") out[rel] = { disconnect: true };
199
- delete out[key];
200
- }
201
- }
202
- }
203
- return out;
204
- };
205
141
 
206
142
  return {
207
143
  async list(entity, config, params) {
208
144
  const prismaModel = model(entity);
209
- const { page = 1, pageSize = 10, search, sort, filters } = params;
210
- const safePage = Math.max(1, Number(page) || 1);
211
- const safePageSize = Math.min(Math.max(1, Number(pageSize) || 10), MAX_PAGE_SIZE);
212
- const skip = (safePage - 1) * safePageSize;
213
- const take = safePageSize;
214
-
215
- const allowedFields = new Set<string>([
216
- ...config.fields.map((f) => f.name),
217
- "id", "createdAt", "updatedAt", "createdBy", "updatedBy", config.idField || "id",
218
- ]);
219
- const allowedRelations = new Set<string>(config.include || []);
220
- const isAllowed = (name: string) => {
221
- if (!name) return false;
222
- if (name.includes(".")) return allowedRelations.has(name.split(".")[0]);
223
- return allowedFields.has(name);
224
- };
225
-
226
- const where: any = { ...(deps.scopeWhere?.(entity, config) ?? {}) };
227
- if (search && search.trim()) {
228
- const term = search.trim();
229
- const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
230
- if (searchFields.length) where.OR = searchFields.map((f) => ({ [f]: { contains: term, mode: "insensitive" } }));
231
- }
232
- if (filters && filters.length) {
233
- for (const filter of filters) {
234
- const { name, value, operator } = filter as any;
235
- if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) continue;
236
- if (!isAllowed(name)) {
237
- log.warn(`Ignoring filter on disallowed field "${name}" for entity "${entity}"`);
238
- continue;
239
- }
240
- let target = where;
241
- let key = name;
242
- if (name.includes(".")) {
243
- const parts = name.split(".");
244
- key = parts.pop()!;
245
- for (const p of parts) { if (!target[p]) target[p] = {}; target = target[p]; }
246
- }
247
- const op = operator as string;
248
- if (op === "contains") target[key] = { contains: value, mode: "insensitive" };
249
- else if (op === "in") target[key] = { in: value };
250
- else if (op === "notIn") target[key] = { notIn: value };
251
- else if (op === "eq") target[key] = value;
252
- else if (op === "ne") target[key] = { not: value };
253
- else if (op === "gt") target[key] = { gt: value };
254
- else if (op === "gte") target[key] = { gte: value };
255
- else if (op === "lt") target[key] = { lt: value };
256
- else if (op === "lte") target[key] = { lte: value };
257
- else if (op === "startsWith") target[key] = { startsWith: value, mode: "insensitive" };
258
- else if (op === "endsWith") target[key] = { endsWith: value, mode: "insensitive" };
259
- else if (op === "isNull") target[key] = null;
260
- else if (op === "isNotNull") target[key] = { not: null };
261
- else target[key] = value;
262
- }
263
- }
264
- // Bộ lọc nâng cao dạng cây ($and/$or, nhiều điều kiện cùng field) —
265
- // cùng guard isAllowed với filter phẳng; AND vào where (search dùng OR
266
- // nên không đụng nhau). Cây sai cấu trúc → throw (route trả lỗi 4xx).
267
- if (params.filterTree) {
268
- const compiled = compileFilterTree(params.filterTree, {
269
- isAllowed,
270
- onDisallowed: (field) =>
271
- log.warn(`Ignoring filterTree condition on disallowed field "${field}" for entity "${entity}"`),
272
- });
273
- if (compiled) {
274
- where.AND = [...(Array.isArray(where.AND) ? where.AND : where.AND ? [where.AND] : []), compiled];
275
- }
276
- }
277
-
278
- const orderBy: any = {};
279
- const applySort = (field: string, direction: any) => {
280
- if (field.includes(".")) {
281
- const parts = field.split(".");
282
- const leaf = parts.pop()!;
283
- let t = orderBy;
284
- for (const p of parts) { t[p] = t[p] || {}; t = t[p]; }
285
- t[leaf] = direction;
286
- } else orderBy[field] = direction;
287
- };
288
- if (sort && isAllowed(sort.field)) applySort(sort.field, sort.direction);
289
- else if (config.defaultSort) applySort(config.defaultSort.field, config.defaultSort.direction);
290
- else if (config.fields.some((f) => f.name === "createdAt")) orderBy.createdAt = "desc";
291
- else orderBy[config.idField || "id"] = "desc";
292
-
293
- const include: any = {};
294
- if (config.include?.length) config.include.forEach((inc) => (include[inc] = true));
295
- const includeOption = Object.keys(include).length ? { include } : {};
145
+
146
+ const {
147
+ where,
148
+ orderBy,
149
+ includeOption,
150
+ skip,
151
+ take,
152
+ safePage,
153
+ safePageSize,
154
+ } = buildListQuery({
155
+ entity,
156
+ config,
157
+ params,
158
+ scopeWhere: deps.scopeWhere?.(entity, config) as Record<string, unknown> | undefined,
159
+ onDisallowedFilter: (field) => log.warn(`Ignoring filter on disallowed field "${field}" for entity "${entity}"`),
160
+ });
296
161
 
297
162
  try {
298
163
  const [total, data] = await Promise.all([
@@ -324,13 +189,15 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
324
189
  async create(entity, data, config, tx) {
325
190
  const prismaModel = model(entity, tx ?? prisma);
326
191
  try {
327
- let filtered = config ? filterValidFields(data, config) : data;
328
- if (config) filtered = castFieldValues(filtered, config);
329
- if (!filtered.id) filtered.id = crypto.randomUUID();
330
- if (deps.touchUpdatedAtOnCreate && !filtered.updatedAt) {
331
- filtered.updatedAt = new Date();
332
- }
333
- const prismaData = transformRelationFields(filtered, "create");
192
+ const prismaData = prepareMutationData({
193
+ data,
194
+ config,
195
+ mode: "create",
196
+ systemFields,
197
+ relationFieldSkip: deps.relationFieldSkip,
198
+ logger: log,
199
+ touchUpdatedAtOnCreate: deps.touchUpdatedAtOnCreate,
200
+ });
334
201
  return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
335
202
  } catch (error) {
336
203
  log.error(`Error creating ${entity}:`, error);
@@ -341,9 +208,14 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
341
208
  async update(entity, id, data, config, tx) {
342
209
  const prismaModel = model(entity, tx ?? prisma);
343
210
  try {
344
- let filtered = config ? filterValidFields(data, config) : data;
345
- if (config) filtered = castFieldValues(filtered, config);
346
- const prismaData = transformRelationFields(filtered, "update");
211
+ const prismaData = prepareMutationData({
212
+ data,
213
+ config,
214
+ mode: "update",
215
+ systemFields,
216
+ relationFieldSkip: deps.relationFieldSkip,
217
+ logger: log,
218
+ });
347
219
  return serializeDecimalFields(await prismaModel.update({ where: { id }, data: prismaData }));
348
220
  } catch (error) {
349
221
  log.error(`Error updating ${entity}:`, error);
@@ -1,103 +1,19 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import { LockManager } from "../lock/lock-manager";
3
- import { WithLock } from "../lock/decorators";
4
- import type { CacheOptions, Cache } from "../cache/types";
5
- import { CacheNamespace } from "../cache/types";
6
-
7
- // Mock Cache Implementation for Testing
8
- class MockCache implements Cache {
9
- private store = new Map<string, any>();
10
- async get<T>(key: string): Promise<T | undefined> {
11
- return this.store.get(key);
12
- }
13
- async set<T>(key: string, value: T): Promise<void> {
14
- this.store.set(key, value);
15
- }
16
- async del(key: string): Promise<void> {
17
- this.store.delete(key);
18
- }
19
- async has(key: string): Promise<boolean> {
20
- return this.store.has(key);
21
- }
22
- async reset(): Promise<void> {
23
- this.store.clear();
24
- }
25
- async keys(): Promise<string[]> {
26
- return Array.from(this.store.keys());
27
- }
28
- }
29
-
30
- describe("Architecture Verification: Phase 2 Enhancements", () => {
31
- describe("2.2 Concurrency Control (LockManager)", () => {
32
- it("should acquire and release lock successfully", async () => {
33
- const cache = new MockCache();
34
- const lockManager = new LockManager(cache);
35
- const resourceId = "order-123";
36
-
37
- const acquired = await lockManager.acquire(resourceId, { ttl: 1000 });
38
- expect(acquired).toBe(true);
39
-
40
- const isLocked = await cache.has(resourceId);
41
- expect(isLocked).toBe(true);
42
-
43
- await lockManager.release(resourceId);
44
- const isLockedAfterRelease = await cache.has(resourceId);
45
- expect(isLockedAfterRelease).toBe(false);
46
- });
47
-
48
- it("should fail to acquire lock if already locked", async () => {
49
- const cache = new MockCache();
50
- const lockManager = new LockManager(cache);
51
- const resourceId = "order-456";
52
-
53
- await lockManager.acquire(resourceId, { ttl: 5000 }); // Lock 1
54
- const acquiredAgain = await lockManager.acquire(resourceId, {
55
- ttl: 1000,
56
- }); // Lock 2
1
+ import { describe, it, expect } from "vitest";
57
2
 
58
- expect(acquiredAgain).toBe(false);
59
- });
60
- });
61
-
62
- describe("2.2 Decorator Usage (@WithLock)", () => {
63
- it("should execute method with lock", async () => {
64
- const cache = new MockCache();
65
- const lockManager = new LockManager(cache);
66
-
67
- class OrderService {
68
- lockManager = lockManager;
69
- executionCount = 0;
70
-
71
- @WithLock("{0}")
72
- async processOrder(orderId: string) {
73
- this.executionCount++;
74
- return `Processed ${orderId}`;
75
- }
76
- }
77
-
78
- const service = new OrderService();
79
- const result = await service.processOrder("100");
3
+ import type { CacheOptions } from "../cache/types";
4
+ import { CacheNamespace } from "../cache/types";
80
5
 
81
- expect(result).toBe("Processed 100");
82
- expect(service.executionCount).toBe(1);
83
- // Lock should be released after execution
84
- expect(await cache.has("lock:order-100")).toBe(false);
85
- });
6
+ describe("Cache: chiến lược namespace", () => {
7
+ it("CacheNamespace khai đúng giá trị", () => {
8
+ expect(CacheNamespace.Auth).toBe("auth");
9
+ expect(CacheNamespace.TenantConfig).toBe("tenant-config");
86
10
  });
87
11
 
88
- describe("2.3 Namespace Caching Strategy", () => {
89
- it("should define CacheNamespace enum correctly", () => {
90
- expect(CacheNamespace.Auth).toBe("auth");
91
- expect(CacheNamespace.TenantConfig).toBe("tenant-config");
92
- });
93
-
94
- it("should allow configuring cache with namespace (Simulation)", () => {
95
- // This tests the TYPE definition and intent, as actual implementation depends on the Factory we haven't fully refactored yet.
96
- const options: CacheOptions = {
97
- name: "redis",
98
- prefix: CacheNamespace.Auth,
99
- };
100
- expect(options.prefix).toBe("auth");
101
- });
12
+ it("prefix nhận namespace", () => {
13
+ const options: CacheOptions = {
14
+ name: "redis",
15
+ prefix: CacheNamespace.Auth,
16
+ };
17
+ expect(options.prefix).toBe("auth");
102
18
  });
103
19
  });
@@ -14,13 +14,10 @@ export type {
14
14
  CacheManagerOptions,
15
15
  } from "./cache";
16
16
 
17
- // EventBus
18
- export { eventBus, EventBusImpl } from "./event-bus";
19
- export type { EventBus, EventHandler, EventSubscription } from "./event-bus";
20
-
21
- // CronJob
22
- export { cronJobManager, CronJobManagerImpl, SimpleCronJob } from "./cron";
23
- export type { CronJob, CronJobOptions, CronJobManager } from "./cron";
17
+ // Lịch chạy nền KHÔNG ở đây: dùng `@goerp/core/cron` (DbCronManager, đọc bảng
18
+ // system_jobs). Bản in-memory đã bỏ — mất hết job khi process restart.
19
+ // Event-bus lock in-memory cũng đã bỏ: chỉ đúng trong 1 tiến trình, mà app
20
+ // thật chạy nhiều instance — khoá thì dùng advisory lock của Postgres.
24
21
 
25
22
  // API Service Layer (Inspired by Plane's Abstract APIService pattern)
26
23
  export { APIService, defaultClientOptions, isAPIError } from "./api-service";
@@ -1,3 +1,4 @@
1
- export * from "./job-management";
2
- export * from "./audit-log-page";
1
+ // Trang quản trị bộ nhớ đệm. Nhật ký hoạt động nằm ở
2
+ // `@goerp/core/system/pages/system-audit-page` (SystemAuditPage) — bản
3
+ // AuditLogPage cũ đọc RAM trong client component nên luôn rỗng, đã bỏ.
3
4
  export * from "./cache-management";
@@ -0,0 +1,41 @@
1
+ # Git & IDE
2
+ .git
3
+ .gitignore
4
+ **/.DS_Store
5
+ **/.idea
6
+ **/.vscode
7
+
8
+ # Kết quả build — dựng lại bên trong Docker
9
+ **/.next
10
+ **/dist
11
+ **/build
12
+ **/.turbo
13
+
14
+ # Cài lại bằng pnpm trong image; giữ context nhỏ
15
+ node_modules
16
+ **/node_modules
17
+
18
+ # Test
19
+ **/__tests__
20
+ **/*.test.ts
21
+ **/*.test.tsx
22
+ **/coverage
23
+
24
+ # Log
25
+ *.log
26
+ **/logs
27
+
28
+ # Env — tuyệt đối không đưa secret vào image
29
+ .env
30
+ **/.env.local
31
+ **/.env.*.local
32
+
33
+ # Tài liệu
34
+ **/docs
35
+ **/*.md
36
+ !**/README.md
37
+
38
+ # ⚠️ Đừng thêm glob trần kiểu **/check-*.ts hay **/seed*.ts: chúng khớp CẢ mã
39
+ # nguồn app (một route handler tên check-*.ts chẳng hạn) → build local xanh
40
+ # (local không đọc .dockerignore) nhưng production 500 "Module not found".
41
+ # Muốn loại script dev thì neo theo thư mục: **/scripts/seed*.ts
@@ -0,0 +1,25 @@
1
+ # ============================================================
2
+ # Sao chép thành .env rồi điền. Ba biến đầu là BẮT BUỘC.
3
+ # ============================================================
4
+
5
+ # Postgres. Prisma 7 đọc qua prisma.config.ts (driver adapter), app đọc trong
6
+ # src/lib/prisma.ts. Cùng một biến, không tách hai.
7
+ DATABASE_URL="postgresql://user:password@localhost:5432/starter_app"
8
+
9
+ # Better Auth. SECRET ký cookie phiên — đổi giá trị này là mọi người bị đăng
10
+ # xuất. Sinh bằng: openssl rand -base64 32
11
+ BETTER_AUTH_SECRET="generate-with: openssl rand -base64 32"
12
+ # URL gốc THẬT của app. Better Auth dùng nó để kiểm Origin của các POST
13
+ # (chống CSRF) — sai giá trị thì đăng nhập trả 403 dù mật khẩu đúng.
14
+ BETTER_AUTH_URL="http://localhost:3010"
15
+
16
+ # Trang chủ sau khi đăng nhập = "/vi" (src/proxy.ts: homePath). Muốn đổi thì
17
+ # sửa thẳng ở đó — đường dẫn PHẢI có locale, để "/" thì proxy localize lại và
18
+ # tạo vòng lặp chuyển hướng.
19
+
20
+ # ---- Tùy chọn ----
21
+
22
+ # Chỉ DEV: dựng vỏ app mà KHÔNG cần đăng nhập (phiên admin giả). Tiện lúc mới
23
+ # clone; bỏ đi khi đã seed tài khoản thật. Đặt trên production cũng vô hiệu —
24
+ # cả app lẫn core đều chặn cứng khi NODE_ENV=production.
25
+ # BYPASS_AUTH=1
@@ -0,0 +1,52 @@
1
+ # <APP-NAME> — Quy tắc cho AI
2
+
3
+ > **Đọc trước:** `goerp-core/AGENTS.md` (nếu goerp-core là repo sibling) — quy tắc
4
+ > nền cho mọi app dựng trên `@goerp/core`. File này chỉ nói phần riêng của app.
5
+
6
+ ## App này (điền khi khởi tạo)
7
+
8
+ - **Nghiệp vụ:** <mô tả domain>.
9
+ - **DB:** `DATABASE_URL` trong `.env`. Nếu đi qua SSH tunnel thì kiểm bằng
10
+ `nc -z localhost <port>` TRƯỚC khi chạy bất cứ lệnh Prisma nào.
11
+ - **Cổng dev:** 3010 (đổi trong `package.json`).
12
+
13
+ ## Luật cứng
14
+
15
+ **1. Tái dùng core, đừng chế lại.** Trước khi viết một màn hình/lớp hạ tầng, tìm
16
+ trong `@goerp/core` xem đã có chưa: trang RBAC, trang cấu hình/nhật ký/tác vụ,
17
+ CRUD engine, export, print, task runner, notification, cron. Cần thêm chức năng
18
+ generic → **bổ sung vào core** (additive) rồi dùng, đừng fork vào app.
19
+
20
+ **2. Không route nào tự gác.** Mọi handler đi qua `apiHandler` ở
21
+ `src/lib/api-handler.ts`; mọi trang cần quyền gọi `requirePageAccess`. Không tự
22
+ viết chuỗi `getSession → 401 → checkPermission → 403`.
23
+
24
+ **3. Quyền phải khai trước khi dùng.** Chuỗi `resource` trong route/menu phải tồn
25
+ tại trong `src/configs/permissions/`. Khai xong chạy `pnpm rbac-sync`. Gõ sai một
26
+ ký tự = nút biến mất vĩnh viễn, không có thông báo. `pnpm test` bắt lỗi này.
27
+
28
+ **4. DB có dữ liệu thật thì chỉ `migrate deploy`.** `prisma migrate dev`,
29
+ `db push`, `migrate reset` (và các tool MCP tương ứng) chỉ dành cho DB trống lúc
30
+ khởi tạo. Với DB đang chạy: viết SQL tay vào `prisma/migrations/<ts>_<tên>/` rồi
31
+ `pnpm prisma:deploy`.
32
+
33
+ **5. Mỗi lần sửa xong: `pnpm type-check` + `pnpm test`.** Không dồn kiểm tra về
34
+ cuối. Ratchet trong `src/__tests__/architecture.test.ts` chỉ được siết chặt thêm,
35
+ không được nới ra.
36
+
37
+ **6. Lỗi phải đi vào `error_logs`.** Route dùng `serverError(error, req)` trong
38
+ catch; đừng nuốt lỗi bằng `console.log` rồi trả 200.
39
+
40
+ ## Bẫy đã trả giá
41
+
42
+ - Đổi schema xong phải **restart `next dev`**. KHÔNG `rm -rf .next` khi dev chạy.
43
+ - Cron của core = `setInterval` tính từ lúc boot, không phải lịch theo giờ. Muốn
44
+ chạy đúng giờ: hẹn mỗi giờ + chặn bằng `isLocalHour()` (`src/instrumentation.ts`).
45
+ - Handler tác vụ nền phải được import qua `src/server/tasks/index.ts` (side-effect
46
+ đăng ký) trước khi có ai enqueue, nếu không tác vụ nằm mãi ở `pending`.
47
+ - Mọi đường ghi cấu hình phải `revalidateTag("system-settings", "max")`.
48
+ - Ma trận quyền của core đọc chuỗi `"action:resource"` — đảo thứ tự thì không ô
49
+ nào tick và cũng chẳng có lỗi nào hiện ra.
50
+ - Refactor lớn: chạy `pnpm build` thật trước khi deploy, và soi lại
51
+ `.dockerignore` — glob trần kiểu `**/check-*.ts` khớp cả mã nguồn app, làm build
52
+ local xanh nhưng production 500 "Module not found".
@@ -0,0 +1,74 @@
1
+ # ─────────────────────────────────────────────────────────────────────────────
2
+ # App độc lập trên @goerp/core — một app Next.js, không monorepo/turbo.
3
+ # Output `standalone` (khai trong next.config.mjs) nên image chỉ chứa server.js
4
+ # + đúng những node_modules đã được trace, không phải cả cây phụ thuộc.
5
+ # ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ # ── Stage 1: base ────────────────────────────────────────────────────────────
8
+ # Node ≥24.15 BẮT BUỘC: vá race TransformStream (nodejs/node#62040) — khách hủy
9
+ # request giữa lúc SSR streaming làm nổ "transformAlgorithm is not a function"
10
+ # trên node:22, và lỗi đó rơi thẳng vào error_logs.
11
+ FROM node:24-alpine AS base
12
+ ENV PNPM_HOME="/pnpm"
13
+ ENV PATH="$PNPM_HOME:$PATH"
14
+ RUN npm install -g pnpm@10.8.1
15
+ # libc6-compat + openssl: engine Prisma cần trên Alpine.
16
+ # tzdata: cấp /usr/share/zoneinfo để biến TZ ở stage runner có tác dụng. Thiếu
17
+ # nó Alpine đứng nguyên UTC, và mọi phép "hôm nay" tính phía server lệch một
18
+ # ngày trong khung 00:00–07:00 giờ VN.
19
+ RUN apk add --no-cache libc6-compat openssl tzdata
20
+
21
+ # ── Stage 2: deps ────────────────────────────────────────────────────────────
22
+ FROM base AS deps
23
+ WORKDIR /app
24
+ COPY package.json pnpm-lock.yaml ./
25
+ RUN pnpm install --frozen-lockfile
26
+
27
+ # ── Stage 3: builder ─────────────────────────────────────────────────────────
28
+ FROM base AS builder
29
+ WORKDIR /app
30
+ COPY --from=deps /app/node_modules ./node_modules
31
+ COPY . .
32
+
33
+ # `prisma generate` đòi DATABASE_URL lúc build. Giá trị giả là đủ để sinh
34
+ # client; URL thật tiêm lúc chạy.
35
+ ARG DATABASE_URL
36
+ ENV DATABASE_URL=${DATABASE_URL:-"postgresql://dummy:dummy@localhost:5432/dummy"}
37
+
38
+ ENV NODE_OPTIONS="--max-old-space-size=4096"
39
+ ENV NEXT_TELEMETRY_DISABLED=1
40
+
41
+ RUN pnpm run build
42
+
43
+ # ── Stage 4: runner ──────────────────────────────────────────────────────────
44
+ FROM base AS runner
45
+ WORKDIR /app
46
+
47
+ RUN addgroup --system --gid 1001 nodejs && \
48
+ adduser --system --uid 1001 nextjs
49
+
50
+ ENV NODE_ENV=production
51
+ ENV PORT=3000
52
+ ENV HOSTNAME="0.0.0.0"
53
+ # Đồng hồ container theo giờ VN để `new Date()` và các phép cắt ngày phía server
54
+ # khớp GMT+7. Dữ liệu đã lưu (created_at) không đổi.
55
+ ENV TZ=Asia/Ho_Chi_Minh
56
+
57
+ # standalone: server.js + node_modules đã trace nằm ngay gốc.
58
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
59
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
60
+ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
61
+
62
+ # File tác vụ nền ghi vào <cwd>/storage khi chưa cấu hình S3/MinIO. Tạo sẵn +
63
+ # cấp quyền, nếu không tác vụ xuất đầu tiên chết vì EACCES. Muốn file sống qua
64
+ # lần deploy sau thì mount volume vào đúng đường dẫn này.
65
+ RUN mkdir -p /app/storage && chown -R nextjs:nodejs /app/storage
66
+
67
+ USER nextjs
68
+ EXPOSE 3000
69
+
70
+ # Di trú KHÔNG chạy ở đây: image standalone không có Prisma CLI. Chạy
71
+ # `pnpm prisma migrate deploy` từ CI (hoặc một container one-shot dựng từ stage
72
+ # `builder`) TRƯỚC khi đổi sang bản mới — như vậy migration hỏng thì dừng ở đó,
73
+ # chứ không làm container khởi động lại vô hạn.
74
+ CMD ["node", "server.js"]