@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.
- package/PLATFORM.md +83 -0
- package/bin/goerp-init.mjs +141 -0
- package/package.json +4 -4
- package/src/branch-scope/__tests__/branch-scope.test.ts +288 -0
- package/src/branch-scope/context.ts +66 -0
- package/src/branch-scope/guard.ts +100 -0
- package/src/branch-scope/index.ts +42 -0
- package/src/branch-scope/scope.ts +149 -0
- package/src/branch-scope/types.ts +57 -0
- package/src/cron/db-cron-manager.ts +3 -3
- package/src/cron/index.ts +2 -2
- package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
- package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
- package/src/infrastructure/index.ts +4 -7
- package/src/ui/management/index.ts +3 -2
- package/templates/starter-app/.dockerignore +41 -0
- package/templates/starter-app/.env.example +25 -0
- package/templates/starter-app/AGENTS.md +52 -0
- package/templates/starter-app/Dockerfile +74 -0
- package/templates/starter-app/README.md +141 -0
- package/templates/starter-app/gitignore +9 -0
- package/templates/starter-app/next.config.mjs +50 -0
- package/templates/starter-app/package.json +55 -0
- package/templates/starter-app/postcss.config.mjs +5 -0
- package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
- package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
- package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
- package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
- package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
- package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
- package/templates/starter-app/prisma/schema/auth.prisma +87 -0
- package/templates/starter-app/prisma/schema/domain.prisma +24 -0
- package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
- package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
- package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
- package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
- package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
- package/templates/starter-app/prisma/schema/organization.prisma +31 -0
- package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
- package/templates/starter-app/prisma/schema/schema.prisma +8 -0
- package/templates/starter-app/prisma/schema/system.prisma +22 -0
- package/templates/starter-app/prisma/seed.ts +127 -0
- package/templates/starter-app/prisma.config.ts +20 -0
- package/templates/starter-app/public/.gitkeep +2 -0
- package/templates/starter-app/scripts/rbac-sync.ts +235 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
- package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
- package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
- package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
- package/templates/starter-app/src/app/api/roles/route.ts +18 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
- package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
- package/templates/starter-app/src/app/api/upload/route.ts +15 -0
- package/templates/starter-app/src/app/globals.css +15 -0
- package/templates/starter-app/src/app/layout.tsx +16 -0
- package/templates/starter-app/src/app/page.tsx +8 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
- package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
- package/templates/starter-app/src/configs/entities/index.ts +13 -0
- package/templates/starter-app/src/configs/i18n.ts +12 -0
- package/templates/starter-app/src/configs/permissions/index.ts +45 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
- package/templates/starter-app/src/configs/permissions/types.ts +63 -0
- package/templates/starter-app/src/configs/tenant.ts +18 -0
- package/templates/starter-app/src/data/dictionary.ts +8 -0
- package/templates/starter-app/src/data/navigations.ts +61 -0
- package/templates/starter-app/src/instrumentation.ts +105 -0
- package/templates/starter-app/src/lib/api-handler.ts +157 -0
- package/templates/starter-app/src/lib/auth-client.ts +57 -0
- package/templates/starter-app/src/lib/auth.ts +62 -0
- package/templates/starter-app/src/lib/better-auth.ts +107 -0
- package/templates/starter-app/src/lib/branch-scope.ts +53 -0
- package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
- package/templates/starter-app/src/lib/crud/index.ts +13 -0
- package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
- package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
- package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
- package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
- package/templates/starter-app/src/lib/logger.ts +30 -0
- package/templates/starter-app/src/lib/page-guard.ts +35 -0
- package/templates/starter-app/src/lib/prisma.ts +80 -0
- package/templates/starter-app/src/lib/rbac/access.ts +87 -0
- package/templates/starter-app/src/lib/storage.ts +28 -0
- package/templates/starter-app/src/providers/index.tsx +54 -0
- package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
- package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
- package/templates/starter-app/src/proxy.ts +45 -0
- package/templates/starter-app/src/server/services/notification-service.ts +31 -0
- package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
- package/templates/starter-app/src/server/tasks/index.ts +16 -0
- package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
- package/templates/starter-app/src/types/session.ts +29 -0
- package/templates/starter-app/tsconfig.json +47 -0
- package/templates/starter-app/vitest.config.ts +17 -0
- package/src/infrastructure/cron/index.ts +0 -6
- package/src/infrastructure/event-bus/event-bus.ts +0 -145
- package/src/infrastructure/event-bus/index.ts +0 -2
- package/src/infrastructure/event-bus/types.ts +0 -22
- package/src/infrastructure/lock/decorators.ts +0 -67
- package/src/infrastructure/lock/index.ts +0 -2
- package/src/infrastructure/lock/lock-manager.ts +0 -33
- package/src/plugin/apps-registry.ts +0 -97
- package/src/plugin/index.ts +0 -5
- package/src/plugin/types.ts +0 -41
- package/src/ui/management/audit-log-page.tsx +0 -14
- package/src/ui/management/job-management.tsx +0 -308
- package/src/workflow/activity-timeline.tsx +0 -412
- package/src/workflow/approval-workflow.tsx +0 -31
- package/src/workflow/index.ts +0 -2
- /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 "
|
|
1
|
+
import { SimpleCronJob } from "./simple-cron-job";
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
CronJob,
|
|
5
5
|
CronJobManager,
|
|
6
6
|
CronJobOptions,
|
|
7
|
-
} from "
|
|
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 `
|
|
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 "
|
|
19
|
+
} from "./simple-cron-job";
|
|
20
20
|
export type {
|
|
21
21
|
CronJob,
|
|
22
22
|
CronJobManager,
|
|
23
23
|
CronJobOptions,
|
|
24
|
-
} from "
|
|
24
|
+
} from "./types";
|
|
@@ -1,103 +1,19 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
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 cũ đã bỏ — mất hết job khi process restart.
|
|
19
|
+
// Event-bus và 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
|
-
|
|
2
|
-
|
|
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".
|