@goplusvn/core 0.1.58 → 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 (145) hide show
  1. package/PLATFORM.md +83 -0
  2. package/bin/goerp-init.mjs +141 -0
  3. package/package.json +4 -4
  4. package/src/branch-scope/__tests__/branch-scope.test.ts +288 -0
  5. package/src/branch-scope/context.ts +66 -0
  6. package/src/branch-scope/guard.ts +100 -0
  7. package/src/branch-scope/index.ts +42 -0
  8. package/src/branch-scope/scope.ts +149 -0
  9. package/src/branch-scope/types.ts +57 -0
  10. package/src/cron/db-cron-manager.ts +3 -3
  11. package/src/cron/index.ts +2 -2
  12. package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
  13. package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
  14. package/src/infrastructure/index.ts +4 -7
  15. package/src/ui/management/index.ts +3 -2
  16. package/templates/starter-app/.dockerignore +41 -0
  17. package/templates/starter-app/.env.example +25 -0
  18. package/templates/starter-app/AGENTS.md +52 -0
  19. package/templates/starter-app/Dockerfile +74 -0
  20. package/templates/starter-app/README.md +141 -0
  21. package/templates/starter-app/gitignore +9 -0
  22. package/templates/starter-app/next.config.mjs +50 -0
  23. package/templates/starter-app/package.json +55 -0
  24. package/templates/starter-app/postcss.config.mjs +5 -0
  25. package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
  26. package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
  27. package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
  28. package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
  29. package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
  30. package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
  31. package/templates/starter-app/prisma/schema/auth.prisma +87 -0
  32. package/templates/starter-app/prisma/schema/domain.prisma +24 -0
  33. package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
  34. package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
  35. package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
  36. package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
  37. package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
  38. package/templates/starter-app/prisma/schema/organization.prisma +31 -0
  39. package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
  40. package/templates/starter-app/prisma/schema/schema.prisma +8 -0
  41. package/templates/starter-app/prisma/schema/system.prisma +22 -0
  42. package/templates/starter-app/prisma/seed.ts +127 -0
  43. package/templates/starter-app/prisma.config.ts +20 -0
  44. package/templates/starter-app/public/.gitkeep +2 -0
  45. package/templates/starter-app/scripts/rbac-sync.ts +235 -0
  46. package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
  47. package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
  48. package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
  49. package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
  50. package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
  51. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
  52. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
  53. package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
  54. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
  55. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
  56. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
  57. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
  58. package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
  59. package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
  60. package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
  61. package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
  62. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
  63. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
  64. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
  65. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
  66. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
  67. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
  68. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
  69. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
  70. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
  71. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
  72. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
  73. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
  74. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
  75. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
  76. package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
  77. package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
  78. package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
  79. package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
  80. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
  81. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
  82. package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
  83. package/templates/starter-app/src/app/api/roles/route.ts +18 -0
  84. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
  85. package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
  86. package/templates/starter-app/src/app/api/upload/route.ts +15 -0
  87. package/templates/starter-app/src/app/globals.css +15 -0
  88. package/templates/starter-app/src/app/layout.tsx +16 -0
  89. package/templates/starter-app/src/app/page.tsx +8 -0
  90. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
  91. package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
  92. package/templates/starter-app/src/configs/entities/index.ts +13 -0
  93. package/templates/starter-app/src/configs/i18n.ts +12 -0
  94. package/templates/starter-app/src/configs/permissions/index.ts +45 -0
  95. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
  96. package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
  97. package/templates/starter-app/src/configs/permissions/types.ts +63 -0
  98. package/templates/starter-app/src/configs/tenant.ts +18 -0
  99. package/templates/starter-app/src/data/dictionary.ts +8 -0
  100. package/templates/starter-app/src/data/navigations.ts +61 -0
  101. package/templates/starter-app/src/instrumentation.ts +105 -0
  102. package/templates/starter-app/src/lib/api-handler.ts +157 -0
  103. package/templates/starter-app/src/lib/auth-client.ts +57 -0
  104. package/templates/starter-app/src/lib/auth.ts +62 -0
  105. package/templates/starter-app/src/lib/better-auth.ts +107 -0
  106. package/templates/starter-app/src/lib/branch-scope.ts +53 -0
  107. package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
  108. package/templates/starter-app/src/lib/crud/index.ts +13 -0
  109. package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
  110. package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
  111. package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
  112. package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
  113. package/templates/starter-app/src/lib/logger.ts +30 -0
  114. package/templates/starter-app/src/lib/page-guard.ts +35 -0
  115. package/templates/starter-app/src/lib/prisma.ts +80 -0
  116. package/templates/starter-app/src/lib/rbac/access.ts +87 -0
  117. package/templates/starter-app/src/lib/storage.ts +28 -0
  118. package/templates/starter-app/src/providers/index.tsx +54 -0
  119. package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
  120. package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
  121. package/templates/starter-app/src/proxy.ts +45 -0
  122. package/templates/starter-app/src/server/services/notification-service.ts +31 -0
  123. package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
  124. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
  125. package/templates/starter-app/src/server/tasks/index.ts +16 -0
  126. package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
  127. package/templates/starter-app/src/types/session.ts +29 -0
  128. package/templates/starter-app/tsconfig.json +47 -0
  129. package/templates/starter-app/vitest.config.ts +17 -0
  130. package/src/infrastructure/cron/index.ts +0 -6
  131. package/src/infrastructure/event-bus/event-bus.ts +0 -145
  132. package/src/infrastructure/event-bus/index.ts +0 -2
  133. package/src/infrastructure/event-bus/types.ts +0 -22
  134. package/src/infrastructure/lock/decorators.ts +0 -67
  135. package/src/infrastructure/lock/index.ts +0 -2
  136. package/src/infrastructure/lock/lock-manager.ts +0 -33
  137. package/src/plugin/apps-registry.ts +0 -97
  138. package/src/plugin/index.ts +0 -5
  139. package/src/plugin/types.ts +0 -41
  140. package/src/ui/management/audit-log-page.tsx +0 -14
  141. package/src/ui/management/job-management.tsx +0 -308
  142. package/src/workflow/activity-timeline.tsx +0 -412
  143. package/src/workflow/approval-workflow.tsx +0 -31
  144. package/src/workflow/index.ts +0 -2
  145. /package/src/{infrastructure/cron → cron}/types.ts +0 -0
@@ -0,0 +1,100 @@
1
+ import { resolveAmbientBranchScope } from "./context";
2
+ import { scopedBranchWhere } from "./scope";
3
+
4
+ import type { BranchScope } from "./types";
5
+
6
+ /**
7
+ * LỚP 2 — lưới an toàn ở tầng ORM (mô hình Odoo/NocoBase): route mới quên lọc
8
+ * chi nhánh thì vẫn không lộ dữ liệu.
9
+ *
10
+ * Chỉ chạm thao tác ĐỌC, chỉ trên model được khai, và chỉ khi có
11
+ * BranchScopeContext — ngoài context (RSC page, cron, webhook, script) extension
12
+ * bất động, nên tác vụ nền không bị cắt dữ liệu bất ngờ.
13
+ *
14
+ * db.$extends(createBranchGuardExtension({
15
+ * models: ["Receipt", "Expense", "CashCount"],
16
+ * // model không có cột branchId thì khai đường đi riêng:
17
+ * buildWhere: (model, scope) =>
18
+ * model === "GoodsReceipt" ? scopedBranchWhere(scope, "warehouse") : null,
19
+ * }))
20
+ *
21
+ * CẢNH BÁO: model đưa vào `models` PHẢI có cột branchId, hoặc phải có nhánh
22
+ * riêng trong `buildWhere`. Chèn điều kiện lên cột không tồn tại là Prisma ném
23
+ * validation error cho MỌI thao tác đọc của user bị scope — nghĩa là hỏng cả
24
+ * nghiệp vụ, không chỉ hỏng bộ lọc.
25
+ */
26
+
27
+ export interface BranchGuardOptions {
28
+ /** Tên model Prisma (đúng chữ hoa/thường như trong schema). */
29
+ models: string[];
30
+ /**
31
+ * Điều kiện guard riêng theo model; trả `null` để dùng mặc định (cột
32
+ * branchId trực tiếp + cho phép null = dùng chung).
33
+ */
34
+ buildWhere?: (
35
+ model: string,
36
+ scope: BranchScope,
37
+ ) => Record<string, unknown> | null;
38
+ /** Mặc định: các thao tác đọc nhiều dòng. */
39
+ operations?: string[];
40
+ }
41
+
42
+ /**
43
+ * findUnique* cố ý không guard: `where` của nó chỉ nhận unique field, chèn thêm
44
+ * điều kiện là Prisma từ chối. Trang chi tiết guard bằng `canAccessBranch`.
45
+ * Mutation cũng không guard — create ghi vào chi nhánh của user, update/delete
46
+ * đi qua kiểm tra quyền riêng.
47
+ */
48
+ const DEFAULT_OPERATIONS = [
49
+ "findMany",
50
+ "findFirst",
51
+ "findFirstOrThrow",
52
+ "count",
53
+ "aggregate",
54
+ "groupBy",
55
+ ];
56
+
57
+ export function createBranchGuardExtension(options: BranchGuardOptions) {
58
+ const models = new Set(options.models);
59
+ const operations = new Set(options.operations ?? DEFAULT_OPERATIONS);
60
+ const buildWhere = options.buildWhere;
61
+
62
+ return {
63
+ name: "branch-guard",
64
+ query: {
65
+ $allModels: {
66
+ // Chữ ký thật do Prisma sinh theo schema của từng app; khai chặt ở core
67
+ // sẽ không assignable ở phía app (kiểu hẹp hơn, contravariance).
68
+ /* eslint-disable @typescript-eslint/no-explicit-any */
69
+ async $allOperations({
70
+ model,
71
+ operation,
72
+ args,
73
+ query,
74
+ }: {
75
+ model?: string;
76
+ operation: string;
77
+ args: any;
78
+ query: (args: any) => Promise<any>;
79
+ }): Promise<any> {
80
+ if (!model || !models.has(model)) return query(args);
81
+ if (!operations.has(operation)) return query(args);
82
+
83
+ const scopePromise = resolveAmbientBranchScope();
84
+ if (!scopePromise) return query(args);
85
+
86
+ const scope = await scopePromise;
87
+ if (scope.canViewAll) return query(args);
88
+
89
+ const guard = buildWhere?.(model, scope) ?? scopedBranchWhere(scope);
90
+ const prevWhere = args?.where;
91
+ return query({
92
+ ...args,
93
+ where: prevWhere ? { AND: [prevWhere, guard] } : guard,
94
+ });
95
+ },
96
+ /* eslint-enable @typescript-eslint/no-explicit-any */
97
+ },
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Phạm vi dữ liệu theo chi nhánh — hai lớp:
3
+ *
4
+ * Lớp 1 (tường minh): `getBranchScope(session)` + `scopedBranchWhere(scope)`
5
+ * trong page/route/service. Đây mới là lớp làm việc chính.
6
+ *
7
+ * Lớp 2 (lưới an toàn): `createBranchGuardExtension` cắm vào Prisma, tự AND
8
+ * điều kiện chi nhánh cho thao tác đọc trên model được khai — cứu chỗ quên.
9
+ *
10
+ * App cấu hình một lần ở composition root và import phạm vi qua đúng file đó
11
+ * (`src/lib/branch-scope.ts`), giống kho tập tin: engine là singleton, import
12
+ * thẳng từ core sẽ lấy được hàm nhưng bỏ lỡ lời gọi cấu hình.
13
+ */
14
+ export {
15
+ configureBranchScope,
16
+ canViewAllBranches,
17
+ getBranchScope,
18
+ canAccessBranch,
19
+ scopedBranchWhere,
20
+ clampBranchFilter,
21
+ clampIdFilter,
22
+ } from "./scope";
23
+
24
+ export {
25
+ runWithBranchScope,
26
+ getBranchScopeContext,
27
+ resolveAmbientBranchScope,
28
+ runWithoutBranchScope,
29
+ type BranchScopeContext,
30
+ } from "./context";
31
+
32
+ export {
33
+ createBranchGuardExtension,
34
+ type BranchGuardOptions,
35
+ } from "./guard";
36
+
37
+ export {
38
+ NO_BRANCH_ACCESS,
39
+ type BranchScope,
40
+ type BranchScopeConfig,
41
+ type BranchScopeDb,
42
+ } from "./types";
@@ -0,0 +1,149 @@
1
+ import {
2
+ NO_BRANCH_ACCESS,
3
+ type BranchScope,
4
+ type BranchScopeConfig,
5
+ } from "./types";
6
+
7
+ /**
8
+ * LỚP 1 — lọc tường minh. Page/route/service gọi `getBranchScope(session)` rồi
9
+ * nhét `scopedBranchWhere(scope)` vào `where`. Lớp 2 (branch-guard extension)
10
+ * là lưới an toàn cho chỗ quên, KHÔNG phải thay thế lớp này: guard chỉ chạm các
11
+ * model được khai và chỉ trong request đi qua apiHandler.
12
+ */
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ let configured: BranchScopeConfig<any> | null = null;
16
+
17
+ export function configureBranchScope<TSession>(
18
+ config: BranchScopeConfig<TSession>,
19
+ ): void {
20
+ configured = config;
21
+ }
22
+
23
+ function requireConfigured() {
24
+ if (!configured) {
25
+ throw new Error(
26
+ "[branch-scope] chưa configureBranchScope(...) — gọi một lần ở composition root (src/lib/branch-scope.ts) rồi import phạm vi qua đúng file đó.",
27
+ );
28
+ }
29
+ return configured;
30
+ }
31
+
32
+ export function canViewAllBranches<TSession>(session: TSession): boolean {
33
+ return requireConfigured().canViewAll(session);
34
+ }
35
+
36
+ /**
37
+ * Phạm vi chi nhánh của user. Chưa được gán chi nhánh nào → sentinel, tức thấy
38
+ * 0 dòng — KHÔNG phải "thấy tất": mặc định an toàn là không thấy gì.
39
+ */
40
+ export async function getBranchScope<TSession>(
41
+ session: TSession,
42
+ ): Promise<BranchScope> {
43
+ const config = requireConfigured();
44
+ if (config.canViewAll(session)) return { canViewAll: true };
45
+
46
+ const branchIds = await readAllowedBranchIds(config, session);
47
+
48
+ return {
49
+ canViewAll: false,
50
+ allowedBranchIds: branchIds.length > 0 ? branchIds : [NO_BRANCH_ACCESS],
51
+ };
52
+ }
53
+
54
+ async function readAllowedBranchIds<TSession>(
55
+ config: BranchScopeConfig<TSession>,
56
+ session: TSession,
57
+ ): Promise<string[]> {
58
+ if (config.getAllowedBranchIds) {
59
+ const ids = await config.getAllowedBranchIds(session);
60
+ return (ids ?? []).filter((id): id is string => Boolean(id));
61
+ }
62
+
63
+ const userId = config.getUserId(session);
64
+ if (!userId) return [];
65
+
66
+ if (!config.db) {
67
+ throw new Error(
68
+ "[branch-scope] configureBranchScope cần `db` (hoặc `getAllowedBranchIds`) để biết user thuộc chi nhánh nào.",
69
+ );
70
+ }
71
+ const rows = await config.db.userBranch.findMany({
72
+ where: { userId },
73
+ select: { branchId: true },
74
+ });
75
+ return rows
76
+ .map((row) => (row as { branchId?: string | null }).branchId)
77
+ .filter((id): id is string => Boolean(id));
78
+ }
79
+
80
+ /**
81
+ * Guard trang chi tiết: user có được xem bản ghi thuộc chi nhánh này không?
82
+ * Bản ghi không gắn chi nhánh (null — dữ liệu cũ hoặc dùng chung) thì ai vào
83
+ * được trang đều xem được.
84
+ */
85
+ export function canAccessBranch(
86
+ scope: BranchScope,
87
+ branchId: string | null | undefined,
88
+ ): boolean {
89
+ if (scope.canViewAll) return true;
90
+ if (!branchId) return true;
91
+ return scope.allowedBranchIds!.includes(branchId);
92
+ }
93
+
94
+ /**
95
+ * Fragment `where` cho model có cột branchId: thuộc CN được phép HOẶC chưa gắn
96
+ * CN. `{}` khi xem được tất — nhét thẳng vào where là xong, không cần rẽ nhánh.
97
+ *
98
+ * `field` cho model scope qua quan hệ, ví dụ kho: `scopedBranchWhere(scope,
99
+ * "warehouse")` → `{ OR: [{ warehouse: { branchId: { in } } }, { warehouse: {
100
+ * branchId: null } }] }`.
101
+ */
102
+ export function scopedBranchWhere(
103
+ scope: BranchScope,
104
+ relation?: string,
105
+ ): Record<string, unknown> {
106
+ if (scope.canViewAll) return {};
107
+ const ids = scope.allowedBranchIds!;
108
+ const inClause = { branchId: { in: ids } };
109
+ const nullClause = { branchId: null };
110
+ return relation
111
+ ? { OR: [{ [relation]: inClause }, { [relation]: nullClause }] }
112
+ : { OR: [inClause, nullClause] };
113
+ }
114
+
115
+ /**
116
+ * Kẹp bộ lọc chi nhánh client gửi lên vào trong phạm vi được phép. `undefined`
117
+ * = client không lọc gì (phạm vi đã do allowedBranchIds lo). Chọn toàn CN ngoài
118
+ * phạm vi → sentinel: thấy 0 dòng, chứ KHÔNG rơi về "không lọc".
119
+ */
120
+ export function clampBranchFilter(
121
+ requested: string[] | string | null | undefined,
122
+ scope: BranchScope,
123
+ ): string[] | undefined {
124
+ const ids = normalizeIds(requested);
125
+ if (ids.length === 0) return undefined;
126
+ if (scope.canViewAll) return ids;
127
+ const valid = ids.filter((id) => scope.allowedBranchIds!.includes(id));
128
+ return valid.length > 0 ? valid : [NO_BRANCH_ACCESS];
129
+ }
130
+
131
+ /**
132
+ * Kẹp danh sách id client chọn vào danh sách được phép — dùng cho thực thể đi
133
+ * theo chi nhánh (kho, quầy, điểm bán). Ngoài danh sách → sentinel.
134
+ */
135
+ export function clampIdFilter(
136
+ requested: string[] | string | null | undefined,
137
+ allowedIds: string[],
138
+ ): string[] {
139
+ const valid = normalizeIds(requested).filter((id) => allowedIds.includes(id));
140
+ return valid.length > 0 ? valid : [NO_BRANCH_ACCESS];
141
+ }
142
+
143
+ function normalizeIds(
144
+ requested: string[] | string | null | undefined,
145
+ ): string[] {
146
+ return (Array.isArray(requested) ? requested : [requested]).filter(
147
+ (id): id is string => Boolean(id && id.trim()),
148
+ );
149
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Phạm vi dữ liệu theo chi nhánh — hợp đồng chung.
3
+ *
4
+ * Bài toán: app nhiều chi nhánh thì "danh sách phiếu thu" của kế toán CN A phải
5
+ * KHÁC của CN B, còn giám đốc thì thấy tất. Lọc bằng tay ở từng route là cách
6
+ * chắc chắn sẽ rò: chỉ cần một route mới quên `where branchId`.
7
+ */
8
+
9
+ /**
10
+ * Sentinel cho "user chưa được gán chi nhánh nào" và "client lọc toàn chi nhánh
11
+ * ngoài phạm vi". Là một chuỗi KHÔNG BAO GIỜ khớp branchId thật, nên
12
+ * `branchId: { in: [NO_BRANCH_ACCESS] }` trả 0 dòng. Không dùng mảng rỗng: Prisma
13
+ * hiểu `in: []` là "không có gì khớp" ở vài chỗ nhưng `undefined`/bỏ mệnh đề ở
14
+ * chỗ khác — nhầm một lần là lộ toàn bộ dữ liệu.
15
+ */
16
+ export const NO_BRANCH_ACCESS = "__NO_ACCESS__";
17
+
18
+ export interface BranchScope {
19
+ canViewAll: boolean;
20
+ /** undefined khi canViewAll — ngược lại LUÔN ≥1 phần tử (sentinel nếu user chưa gán CN). */
21
+ allowedBranchIds?: string[];
22
+ }
23
+
24
+ /** Chỉ cần đúng phần delegate `user_branches` mà scope dùng tới. */
25
+ export interface BranchScopeDb {
26
+ userBranch: {
27
+ findMany: (args: {
28
+ where: { userId: string };
29
+ select: { branchId: true };
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ }) => Promise<any[]>;
32
+ };
33
+ }
34
+
35
+ export interface BranchScopeConfig<TSession = unknown> {
36
+ /**
37
+ * Nguồn mặc định của phạm vi: bảng `user_branches`. Bỏ trống được NẾU đã khai
38
+ * `getAllowedBranchIds`.
39
+ */
40
+ db?: BranchScopeDb;
41
+ /**
42
+ * Session đã mang sẵn danh sách chi nhánh (app nhồi vào lúc đăng nhập) thì
43
+ * khai ở đây — khỏi phải truy vấn user_branches mỗi lần cần phạm vi. Trả mảng
44
+ * rỗng/null = user chưa được gán chi nhánh nào (→ sentinel, thấy 0 dòng).
45
+ */
46
+ getAllowedBranchIds?: (
47
+ session: TSession,
48
+ ) => string[] | null | undefined | Promise<string[] | null | undefined>;
49
+ /** Lấy id user từ session của app. */
50
+ getUserId: (session: TSession) => string | null | undefined;
51
+ /**
52
+ * "Xem mọi chi nhánh" — app tự quyết: vai trò quản trị, hoặc quyền
53
+ * `view-all-branches` trên resource nào đó. Trả true thì scope bỏ qua luôn
54
+ * truy vấn user_branches.
55
+ */
56
+ canViewAll: (session: TSession) => boolean;
57
+ }
@@ -1,15 +1,15 @@
1
- import { SimpleCronJob } from "../infrastructure/cron/cron-manager";
1
+ import { SimpleCronJob } from "./simple-cron-job";
2
2
 
3
3
  import type {
4
4
  CronJob,
5
5
  CronJobManager,
6
6
  CronJobOptions,
7
- } from "../infrastructure/cron/types";
7
+ } from "./types";
8
8
 
9
9
  /**
10
10
  * Cron CÓ TRẠNG THÁI TRONG DB — engine dùng chung mọi app goerp (bảng
11
11
  * `system_jobs` + `job_execution_logs` ship qua `goerp-features sync`, feature
12
- * system-jobs). Bản `infrastructure/cron` chỉ hẹn giờ trong RAM: restart là
12
+ * system-jobs). Bản `SimpleCronJob` cạnh đây chỉ hẹn giờ trong RAM: restart là
13
13
  * mất bật/tắt, không có lịch sử chạy, admin không nhìn thấy gì.
14
14
  *
15
15
  * Vai trò từng lớp:
package/src/cron/index.ts CHANGED
@@ -16,9 +16,9 @@ export {
16
16
  cronJobManager,
17
17
  CronJobManagerImpl,
18
18
  SimpleCronJob,
19
- } from "../infrastructure/cron/cron-manager";
19
+ } from "./simple-cron-job";
20
20
  export type {
21
21
  CronJob,
22
22
  CronJobManager,
23
23
  CronJobOptions,
24
- } from "../infrastructure/cron/types";
24
+ } from "./types";
@@ -3,7 +3,7 @@ import type {
3
3
  CronJobManager as ICronJobManager,
4
4
  CronJobOptions,
5
5
  } from "./types";
6
- import { createLogger } from "../logger";
6
+ import { createLogger } from "../infrastructure/logger";
7
7
 
8
8
  const logger = createLogger("CronJobManager");
9
9
 
@@ -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".