@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.
- package/PLATFORM.md +18 -0
- package/bin/goerp-init.mjs +141 -0
- package/package.json +4 -4
- 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/crud/lib/mutation-builder.ts +105 -0
- package/src/crud/lib/query-builder.ts +119 -0
- package/src/crud/server-service.ts +35 -163
- 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,131 @@
|
|
|
1
|
+
import type { FeaturePermissions } from "./types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quản trị hệ thống — nhóm resource đi kèm các trang có sẵn của @goerp/core
|
|
5
|
+
* (phân quyền, cấu hình, nhật ký, tác vụ nền). Giữ nguyên MÃ resource: chúng
|
|
6
|
+
* khớp với đường dẫn trang và với chuỗi checkPermission trong route.
|
|
7
|
+
*
|
|
8
|
+
* Bộ action khai theo NGHĨA thực: nhật ký thì không có create/update, tác vụ
|
|
9
|
+
* nền thì không xoá bằng tay…
|
|
10
|
+
*/
|
|
11
|
+
const systemPermissions: FeaturePermissions = {
|
|
12
|
+
feature: "system",
|
|
13
|
+
description: "Quản trị hệ thống: phân quyền, cấu hình, nhật ký",
|
|
14
|
+
customActions: [
|
|
15
|
+
{
|
|
16
|
+
code: "cancel",
|
|
17
|
+
name: "Hủy",
|
|
18
|
+
description: "Dừng một tác vụ nền đang chạy hoặc đang chờ.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
code: "view-all-branches",
|
|
22
|
+
name: "Xem mọi chi nhánh",
|
|
23
|
+
description:
|
|
24
|
+
"Bỏ giới hạn phạm vi dữ liệu: thấy chứng từ của mọi chi nhánh, không chỉ chi nhánh được gán (src/lib/branch-scope.ts).",
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
resources: [
|
|
28
|
+
// ── Phân quyền ───────────────────────────────────────────────────────
|
|
29
|
+
{
|
|
30
|
+
code: "role",
|
|
31
|
+
name: "Vai trò",
|
|
32
|
+
group: "Phân quyền",
|
|
33
|
+
icon: "ShieldCheck",
|
|
34
|
+
order: 10,
|
|
35
|
+
actions: ["view", "create", "update", "delete", "export"],
|
|
36
|
+
defaultGrants: { admin: "*" },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
code: "resource",
|
|
40
|
+
name: "Tài nguyên",
|
|
41
|
+
group: "Phân quyền",
|
|
42
|
+
icon: "Database",
|
|
43
|
+
order: 11,
|
|
44
|
+
actions: ["view", "create", "update", "delete"],
|
|
45
|
+
defaultGrants: { admin: "*" },
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
code: "action",
|
|
49
|
+
name: "Hành động",
|
|
50
|
+
group: "Phân quyền",
|
|
51
|
+
icon: "Zap",
|
|
52
|
+
order: 12,
|
|
53
|
+
actions: ["view", "create", "update", "delete"],
|
|
54
|
+
defaultGrants: { admin: "*" },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
code: "user",
|
|
58
|
+
name: "Người dùng",
|
|
59
|
+
group: "Phân quyền",
|
|
60
|
+
icon: "Users",
|
|
61
|
+
order: 13,
|
|
62
|
+
actions: ["view", "create", "update", "delete", "export"],
|
|
63
|
+
defaultGrants: { admin: "*" },
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// ── Cấu hình ─────────────────────────────────────────────────────────
|
|
67
|
+
{
|
|
68
|
+
code: "system-setting",
|
|
69
|
+
name: "Cấu hình chung",
|
|
70
|
+
group: "Hệ thống",
|
|
71
|
+
icon: "Settings",
|
|
72
|
+
order: 20,
|
|
73
|
+
actions: ["view", "create", "update", "delete", "export"],
|
|
74
|
+
defaultGrants: { admin: "*" },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
code: "branch",
|
|
78
|
+
name: "Chi nhánh",
|
|
79
|
+
group: "Hệ thống",
|
|
80
|
+
icon: "Building",
|
|
81
|
+
order: 21,
|
|
82
|
+
// "view-all-branches" là quyền PHẠM VI DỮ LIỆU, không phải một màn hình:
|
|
83
|
+
// cấp cho vai trò nào thì vai trò đó thoát bộ lọc chi nhánh ở mọi nơi.
|
|
84
|
+
actions: ["view", "create", "update", "delete", "view-all-branches"],
|
|
85
|
+
// Mọi người đều cần đọc danh sách chi nhánh để đổ vào bộ lọc/picker.
|
|
86
|
+
lookupPolicy: "authenticated",
|
|
87
|
+
defaultGrants: { admin: "*" },
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// ── Nhật ký & giám sát ───────────────────────────────────────────────
|
|
91
|
+
{
|
|
92
|
+
code: "audit-log",
|
|
93
|
+
name: "Nhật ký hoạt động",
|
|
94
|
+
group: "Hệ thống",
|
|
95
|
+
icon: "Scroll",
|
|
96
|
+
order: 30,
|
|
97
|
+
// Nhật ký chỉ đọc/xuất/dọn — không create/update.
|
|
98
|
+
actions: ["view", "delete", "export"],
|
|
99
|
+
defaultGrants: { admin: "*" },
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
code: "error-log",
|
|
103
|
+
name: "Nhật ký lỗi",
|
|
104
|
+
group: "Hệ thống",
|
|
105
|
+
icon: "Bug",
|
|
106
|
+
order: 31,
|
|
107
|
+
actions: ["view", "update", "delete"],
|
|
108
|
+
defaultGrants: { admin: "*" },
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
code: "system-job",
|
|
112
|
+
name: "Tác vụ định kỳ",
|
|
113
|
+
group: "Hệ thống",
|
|
114
|
+
icon: "Cpu",
|
|
115
|
+
order: 32,
|
|
116
|
+
actions: ["view", "update"],
|
|
117
|
+
defaultGrants: { admin: "*" },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
code: "background-task",
|
|
121
|
+
name: "Tác vụ nền",
|
|
122
|
+
group: "Hệ thống",
|
|
123
|
+
icon: "ListChecks",
|
|
124
|
+
order: 33,
|
|
125
|
+
actions: ["view", "cancel"],
|
|
126
|
+
defaultGrants: { admin: "*", staff: ["view"] },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default systemPermissions
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PERMISSION REGISTRY — nguồn chân lý DUY NHẤT về quyền của app.
|
|
3
|
+
*
|
|
4
|
+
* Mỗi phân hệ có một file `<feature>.permissions.ts` khai đủ resource, action,
|
|
5
|
+
* nhóm hiển thị và grant mặc định. `scripts/rbac-sync.ts` đọc registry rồi
|
|
6
|
+
* upsert xuống DB lúc deploy — nhờ vậy không còn cảnh mỗi tính năng mới lại
|
|
7
|
+
* thêm một script vá RBAC rời rạc, rồi quên chạy trên production.
|
|
8
|
+
*
|
|
9
|
+
* RÀNG BUỘC: file trong thư mục này phải là DỮ LIỆU THUẦN — không import
|
|
10
|
+
* prisma/next/react. Script tsx và test đọc chúng trực tiếp, kéo theo app là
|
|
11
|
+
* hỏng cả hai.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Chính sách đọc-để-tra-cứu: cho phép đổ dữ liệu vào combo/picker mà không cần
|
|
16
|
+
* quyền `view` của trang quản lý. Áp tại GET danh sách, KHÔNG áp cho trang quản lý.
|
|
17
|
+
*
|
|
18
|
+
* - "authenticated": ai đăng nhập cũng tra được (danh mục vô hại: kho, đơn vị tính…)
|
|
19
|
+
* - "view-only": không có đường tắt, đọc là phải có `<resource>:view`. MẶC ĐỊNH.
|
|
20
|
+
*/
|
|
21
|
+
export type LookupPolicy = "authenticated" | "view-only"
|
|
22
|
+
|
|
23
|
+
export interface ActionDeclaration {
|
|
24
|
+
code: string
|
|
25
|
+
name: string
|
|
26
|
+
/** Dùng làm gì, ở trang nào — hiện thành tooltip trên trang phân quyền. */
|
|
27
|
+
description?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResourceDeclaration {
|
|
31
|
+
/** Mã resource — PHẢI khớp chuỗi dùng trong checkPermission và navigations. */
|
|
32
|
+
code: string
|
|
33
|
+
/** Tên hiển thị trên trang phân quyền. */
|
|
34
|
+
name: string
|
|
35
|
+
/** Phân hệ — ma trận quyền nhóm theo cột này. */
|
|
36
|
+
group: string
|
|
37
|
+
description?: string
|
|
38
|
+
icon?: string
|
|
39
|
+
order?: number
|
|
40
|
+
/**
|
|
41
|
+
* Action áp dụng cho resource. Action ngoài bộ chuẩn
|
|
42
|
+
* (view/create/update/delete/export/import) phải khai trong `customActions`
|
|
43
|
+
* của feature, nếu không rbac-sync sẽ dừng và báo lỗi.
|
|
44
|
+
*/
|
|
45
|
+
actions: readonly string[]
|
|
46
|
+
/** Mặc định "view-only". */
|
|
47
|
+
lookupPolicy?: LookupPolicy
|
|
48
|
+
/**
|
|
49
|
+
* Grant mặc định roleCode → action ("*" = mọi action đã khai). CHỈ seed khi
|
|
50
|
+
* resource xuất hiện LẦN ĐẦU — sync không bao giờ ghi đè chỉnh sửa của admin.
|
|
51
|
+
*/
|
|
52
|
+
defaultGrants?: Readonly<Record<string, readonly string[] | "*">>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FeaturePermissions {
|
|
56
|
+
/** Định danh phân hệ, trùng tên file (bỏ đuôi .permissions.ts). */
|
|
57
|
+
feature: string
|
|
58
|
+
/** Mô tả ngắn — hiện thành nhãn nguồn gốc trên trang Tài nguyên. */
|
|
59
|
+
description?: string
|
|
60
|
+
/** Action riêng của phân hệ (approve, confirm-payment…). */
|
|
61
|
+
customActions?: readonly ActionDeclaration[]
|
|
62
|
+
resources: readonly ResourceDeclaration[]
|
|
63
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TenantConfig } from "@goerp/core/providers"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Branding + tenant config for this app. Editing this re-skins the whole app —
|
|
5
|
+
* logo, name and (via `primaryColor`) the brand color, with no globals.css edit.
|
|
6
|
+
*/
|
|
7
|
+
export const tenant: TenantConfig = {
|
|
8
|
+
id: "starter",
|
|
9
|
+
name: "GoERP Starter",
|
|
10
|
+
branding: {
|
|
11
|
+
// logo: "/logo.png",
|
|
12
|
+
companyName: "GoERP Starter",
|
|
13
|
+
tagline: "Powered by @goerp/core",
|
|
14
|
+
// primaryColor: "262 83% 58%", // HSL triple → reskins --primary at runtime
|
|
15
|
+
// favicon: "/favicon.ico",
|
|
16
|
+
},
|
|
17
|
+
currency: "VND",
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DictionaryType } from "@goerp/core/hooks"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* i18n dictionary for the shell. The sidebar falls back to the raw nav title
|
|
5
|
+
* when a key is missing, so an empty object works to start. Grow it into
|
|
6
|
+
* `{ navigation: { ... }, label: { ... } }` as you localize.
|
|
7
|
+
*/
|
|
8
|
+
export const dictionary: DictionaryType = {}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { NavigationType } from "@goerp/core/types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sidebar navigation. Each top-level entry is a GROUP (title + iconName) with
|
|
5
|
+
* `items`. `iconName` is a lucide icon name (PascalCase). `resource` ties an
|
|
6
|
+
* item to an RBAC resource code so it hides when the user lacks permission.
|
|
7
|
+
*
|
|
8
|
+
* Entity pages go through the generic route /crud/<key> (see
|
|
9
|
+
* src/app/[lang]/(main)/crud/[entity]/page.tsx) — the `<key>` must match the
|
|
10
|
+
* registry key in src/configs/entities and the CRUD model map.
|
|
11
|
+
*/
|
|
12
|
+
export const navigations: NavigationType[] = [
|
|
13
|
+
{
|
|
14
|
+
title: "Tổng quan",
|
|
15
|
+
iconName: "LayoutDashboard",
|
|
16
|
+
items: [{ title: "Trang chủ", iconName: "House", href: "/" }],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: "Quản lý",
|
|
20
|
+
iconName: "Settings",
|
|
21
|
+
items: [
|
|
22
|
+
{
|
|
23
|
+
title: "Phòng ban",
|
|
24
|
+
iconName: "Building2",
|
|
25
|
+
href: "/crud/departments",
|
|
26
|
+
resource: "department",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
title: "Hệ thống",
|
|
32
|
+
iconName: "ShieldCheck",
|
|
33
|
+
items: [
|
|
34
|
+
{ title: "Vai trò", iconName: "ShieldCheck", href: "/roles", resource: "role" },
|
|
35
|
+
{
|
|
36
|
+
title: "Cấu hình",
|
|
37
|
+
iconName: "Settings2",
|
|
38
|
+
href: "/admin/system/settings",
|
|
39
|
+
resource: "system-setting",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: "Nhật ký hoạt động",
|
|
43
|
+
iconName: "Scroll",
|
|
44
|
+
href: "/admin/system/audit",
|
|
45
|
+
resource: "audit-log",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
title: "Nhật ký lỗi",
|
|
49
|
+
iconName: "Bug",
|
|
50
|
+
href: "/admin/system/error-logs",
|
|
51
|
+
resource: "error-log",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
title: "Tác vụ định kỳ",
|
|
55
|
+
iconName: "Cpu",
|
|
56
|
+
href: "/admin/system/jobs",
|
|
57
|
+
resource: "system-job",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Điểm khởi động phía server của Next.js. Chạy MỘT LẦN mỗi tiến trình, trước
|
|
3
|
+
* request đầu tiên — chỗ duy nhất hợp lý để bật cron và dọn tác vụ mồ côi.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function register() {
|
|
7
|
+
// Hook này cũng chạy trên edge runtime, nơi không có Prisma. Chặn sớm.
|
|
8
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return
|
|
9
|
+
|
|
10
|
+
const { cronManager } = await import("./lib/cron/db-cron-manager")
|
|
11
|
+
const { logger } = await import("./lib/logger")
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await cronManager.init()
|
|
15
|
+
|
|
16
|
+
// Tác vụ nền còn "running" là của tiến trình ĐÃ CHẾT (deploy/restart giữa
|
|
17
|
+
// chừng) — đánh dấu lỗi để user chạy lại, thay vì để quay vòng vĩnh viễn.
|
|
18
|
+
void import("@/server/tasks")
|
|
19
|
+
.then(({ reclaimStaleTasks }) => reclaimStaleTasks())
|
|
20
|
+
.catch((e) => logger.error("Reclaim background tasks failed", e))
|
|
21
|
+
|
|
22
|
+
// ── Việc định kỳ ────────────────────────────────────────────────────
|
|
23
|
+
// Thêm job của bạn ở đây. Lịch chạy lưu trong bảng `system_jobs`, admin
|
|
24
|
+
// đổi được trên /admin/system/jobs mà không cần deploy lại; `cronTime` bên
|
|
25
|
+
// dưới chỉ là giá trị mặc định cho lần tạo đầu tiên.
|
|
26
|
+
//
|
|
27
|
+
// Job PHẢI idempotent: container restart có thể làm nó chạy lại.
|
|
28
|
+
cronManager.addJob({
|
|
29
|
+
name: "system-heartbeat",
|
|
30
|
+
cronTime: "0 * * * *",
|
|
31
|
+
start: true,
|
|
32
|
+
onTick: async () => {
|
|
33
|
+
logger.debug("heartbeat")
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
logger.info("System jobs registered")
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// Cron hỏng thì app vẫn phải phục vụ được request.
|
|
40
|
+
logger.error("Failed to register system jobs", error)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* BẪY lịch chạy: bộ hẹn giờ của core quy cron về `setInterval` TỪ LÚC BOOT —
|
|
46
|
+
* "0 1 * * *" thành "cứ 24 giờ kể từ khi khởi động", không phải "1 giờ sáng".
|
|
47
|
+
* Container deploy vài lần một ngày thì job hằng ngày gần như không bao giờ
|
|
48
|
+
* chạy đúng giờ mong muốn.
|
|
49
|
+
*
|
|
50
|
+
* Cách chữa: hẹn MỖI GIỜ (`0 * * * *`) rồi tự gác bằng hàm này. Mỗi giờ đồng
|
|
51
|
+
* hồ chỉ có đúng một tick nên không chạy trùng.
|
|
52
|
+
*
|
|
53
|
+
* cronManager.addJob({
|
|
54
|
+
* name: "daily-report",
|
|
55
|
+
* cronTime: "0 * * * *",
|
|
56
|
+
* start: true,
|
|
57
|
+
* onTick: async () => {
|
|
58
|
+
* if (!isLocalHour(1)) return
|
|
59
|
+
* await sendDailyReport()
|
|
60
|
+
* },
|
|
61
|
+
* })
|
|
62
|
+
*/
|
|
63
|
+
export function isLocalHour(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolean {
|
|
64
|
+
return (
|
|
65
|
+
Number(
|
|
66
|
+
new Intl.DateTimeFormat("en-GB", {
|
|
67
|
+
hour: "numeric",
|
|
68
|
+
hour12: false,
|
|
69
|
+
timeZone,
|
|
70
|
+
}).format(new Date()),
|
|
71
|
+
) === hour
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lưới an toàn cho lỗi KHÔNG được bắt: route handler ném ra ngoài, lỗi render
|
|
77
|
+
* RSC… Chỗ nào tự bắt rồi trả response thì dùng `serverError()`; hook này lo
|
|
78
|
+
* phần còn lại để một cú crash vẫn để lại stack trong `error_logs` chứ không
|
|
79
|
+
* chỉ trôi qua stdout.
|
|
80
|
+
*/
|
|
81
|
+
export async function onRequestError(
|
|
82
|
+
error: unknown,
|
|
83
|
+
request: { path?: string; method?: string; headers?: Record<string, string> },
|
|
84
|
+
context: { routePath?: string; routeType?: string; routerKind?: string },
|
|
85
|
+
) {
|
|
86
|
+
// logServerError kéo Prisma vào — không có trên edge runtime.
|
|
87
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return
|
|
88
|
+
try {
|
|
89
|
+
const { logServerError } = await import("./lib/errors/log-server-error")
|
|
90
|
+
logServerError(error, {
|
|
91
|
+
url: request?.path,
|
|
92
|
+
method: request?.method,
|
|
93
|
+
userAgent: request?.headers?.["user-agent"],
|
|
94
|
+
severity: "error",
|
|
95
|
+
extra: {
|
|
96
|
+
source: "onRequestError",
|
|
97
|
+
routePath: context?.routePath,
|
|
98
|
+
routeType: context?.routeType,
|
|
99
|
+
routerKind: context?.routerKind,
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
} catch {
|
|
103
|
+
// Không bao giờ để việc ghi lỗi tự ném lỗi bên trong hook xử lý lỗi.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CỔNG của tầng API. Mọi route handler đi qua đây thay vì tự viết chuỗi
|
|
3
|
+
* `getSession → 401 → checkPermission → 403 → try/catch`.
|
|
4
|
+
*
|
|
5
|
+
* export const GET = apiHandler(handler) // chỉ cần đăng nhập
|
|
6
|
+
* export const POST = apiHandler(handler, { resource: "department", action: "create" })
|
|
7
|
+
* export const GET = apiHandler(handler, { public: true }) // không gác
|
|
8
|
+
*
|
|
9
|
+
* Khung ở @goerp/core/auth/api-handler; file này cắm phần thuộc về app:
|
|
10
|
+
* - nguồn session + luật quyền,
|
|
11
|
+
* - kiểm resource có trong Permission Registry (bắt lỗi gõ sai ngay lúc load),
|
|
12
|
+
* - audit-context (ai đang thao tác — extension Prisma đọc để ghi audit_logs),
|
|
13
|
+
* - ghi lỗi vào error_logs.
|
|
14
|
+
*/
|
|
15
|
+
import { NextResponse } from "next/server"
|
|
16
|
+
import { withAuditContext } from "@goerp/core/audit"
|
|
17
|
+
import { checkPermission as coreCheckPermission } from "@goerp/core/auth"
|
|
18
|
+
import {
|
|
19
|
+
createApiHandler,
|
|
20
|
+
type ApiHandlerMiddleware,
|
|
21
|
+
} from "@goerp/core/auth/api-handler"
|
|
22
|
+
|
|
23
|
+
import type { NextRequest } from "next/server"
|
|
24
|
+
import type { Session } from "@/types/session"
|
|
25
|
+
|
|
26
|
+
import { getRegistryResource } from "@/configs/permissions"
|
|
27
|
+
import { getSession } from "@/lib/auth"
|
|
28
|
+
import { getBranchScope, runWithBranchScope } from "@/lib/branch-scope"
|
|
29
|
+
import { logServerError } from "@/lib/errors/log-server-error"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `TParams` mở ra cho route catch-all: `/api/files/[...key]` nhận
|
|
33
|
+
* `{ key: string[] }` chứ không phải `Record<string, string>`, khai cứng thì
|
|
34
|
+
* validator sinh tự động của Next báo lỗi kiểu ở mọi route dạng đó.
|
|
35
|
+
*/
|
|
36
|
+
interface HandlerContext<TParams = Record<string, string>> {
|
|
37
|
+
session: Session
|
|
38
|
+
params: Promise<TParams>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Handler<TParams = Record<string, string>> = (
|
|
42
|
+
req: NextRequest,
|
|
43
|
+
ctx: HandlerContext<TParams>,
|
|
44
|
+
) => Promise<Response>
|
|
45
|
+
|
|
46
|
+
interface ApiHandlerOptions {
|
|
47
|
+
resource?: string
|
|
48
|
+
action?: string
|
|
49
|
+
/** true → endpoint public, bỏ qua cả xác thực. */
|
|
50
|
+
public?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resource phải tồn tại trong Permission Registry. Gõ sai một ký tự sẽ khiến
|
|
55
|
+
* quyền KHÔNG BAO GIỜ khớp — nút biến mất, API trả 403, mà không có thông báo
|
|
56
|
+
* nào. Chạy lúc đăng ký handler (module load), không phải mỗi request: dev/test
|
|
57
|
+
* ném lỗi ngay, production chỉ cảnh báo để drift không làm sập app đang chạy.
|
|
58
|
+
*/
|
|
59
|
+
function assertResourceDeclared(resource: string) {
|
|
60
|
+
if (getRegistryResource(resource)) return
|
|
61
|
+
const message =
|
|
62
|
+
`[api-handler] resource "${resource}" chưa khai trong Permission Registry ` +
|
|
63
|
+
`(src/configs/permissions) — thêm vào <feature>.permissions.ts rồi chạy pnpm rbac-sync.`
|
|
64
|
+
if (process.env.NODE_ENV === "production") console.warn(message)
|
|
65
|
+
else throw new Error(message)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Gắn "ai đang thao tác" vào AsyncLocalStorage cho cả request. Extension audit
|
|
70
|
+
* trong lib/prisma.ts đọc context này; thiếu nó thì mọi dòng audit_logs đều
|
|
71
|
+
* ghi tác nhân rỗng.
|
|
72
|
+
*/
|
|
73
|
+
const auditContext: ApiHandlerMiddleware<Session> = async (next, { req, session }) => {
|
|
74
|
+
if (!session) return next()
|
|
75
|
+
const ip =
|
|
76
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
77
|
+
req.headers.get("x-real-ip") ||
|
|
78
|
+
undefined
|
|
79
|
+
return (await withAuditContext(
|
|
80
|
+
{ userId: session.user.id, userName: session.user.name, ip },
|
|
81
|
+
() => next(),
|
|
82
|
+
)) as Response
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Mở context phạm vi chi nhánh cho cả request để branch-guard (lib/prisma.ts)
|
|
87
|
+
* biết user được xem chi nhánh nào. Phạm vi được tính LƯỜI — request không đụng
|
|
88
|
+
* model nào bị guard thì `resolve` không bao giờ chạy.
|
|
89
|
+
*/
|
|
90
|
+
const branchScope: ApiHandlerMiddleware<Session> = async (next, { session }) => {
|
|
91
|
+
if (!session) return next()
|
|
92
|
+
return (await runWithBranchScope(
|
|
93
|
+
{ resolve: () => getBranchScope(session) },
|
|
94
|
+
() => next(),
|
|
95
|
+
)) as Response
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lỗi chưa bắt của route: in ra terminal + lưu error_logs + 500. */
|
|
99
|
+
async function onApiError(error: unknown, req: Request): Promise<Response> {
|
|
100
|
+
console.error(`[API] ${req.method} ${req.url} error:`, error)
|
|
101
|
+
|
|
102
|
+
const session = await getSession().catch(() => null)
|
|
103
|
+
// Fire-and-forget — việc ghi log không được làm chậm phản hồi lỗi.
|
|
104
|
+
logServerError(error, {
|
|
105
|
+
url: req.url,
|
|
106
|
+
method: req.method,
|
|
107
|
+
userId: session?.user?.id,
|
|
108
|
+
userAgent: req.headers.get("user-agent") || undefined,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const message = error instanceof Error ? error.message : "Internal Server Error"
|
|
112
|
+
return NextResponse.json(
|
|
113
|
+
{
|
|
114
|
+
error: message,
|
|
115
|
+
// Ở dev trả kèm stack để showError() phía client hiện đúng thứ terminal
|
|
116
|
+
// đang in — production thì tuyệt đối không lộ.
|
|
117
|
+
...(process.env.NODE_ENV === "production"
|
|
118
|
+
? {}
|
|
119
|
+
: { debug: { message, stack: error instanceof Error ? error.stack : undefined } }),
|
|
120
|
+
},
|
|
121
|
+
{ status: 500 },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const coreApiHandler = createApiHandler<Session>({
|
|
126
|
+
// Session có nhưng thiếu user thì coi như chưa đăng nhập.
|
|
127
|
+
getSession: async () => {
|
|
128
|
+
const session = await getSession()
|
|
129
|
+
return session?.user ? session : null
|
|
130
|
+
},
|
|
131
|
+
checkPermission: (session, resource, action) =>
|
|
132
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
133
|
+
coreCheckPermission(session as any, resource, action),
|
|
134
|
+
assertResource: assertResourceDeclared,
|
|
135
|
+
wrap: [auditContext, branchScope],
|
|
136
|
+
onError: onApiError,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
export function apiHandler<TParams = Record<string, string>>(
|
|
140
|
+
handler: Handler<TParams>,
|
|
141
|
+
options?: ApiHandlerOptions,
|
|
142
|
+
) {
|
|
143
|
+
const route = coreApiHandler(
|
|
144
|
+
(req, ctx) =>
|
|
145
|
+
handler(req as NextRequest, {
|
|
146
|
+
// Đường public không có session; handler public không được đọc user.
|
|
147
|
+
session: ctx.session ?? ({} as Session),
|
|
148
|
+
params: ctx.params as Promise<TParams>,
|
|
149
|
+
}),
|
|
150
|
+
options,
|
|
151
|
+
)
|
|
152
|
+
return async (
|
|
153
|
+
req: NextRequest,
|
|
154
|
+
// Route tĩnh (không có segment động) được Next gọi mà không kèm ctx.
|
|
155
|
+
routeCtx?: { params: Promise<TParams> },
|
|
156
|
+
): Promise<Response> => route(req, routeCtx as never)
|
|
157
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { customSessionClient } from "better-auth/client/plugins"
|
|
4
|
+
import { createAuthClient } from "better-auth/react"
|
|
5
|
+
|
|
6
|
+
import type { AuthBridgeClient } from "@goerp/core/ui"
|
|
7
|
+
import type { getAuth } from "@/lib/better-auth"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Client Better Auth + cầu nối sang `AuthBridgeClient` của core.
|
|
11
|
+
*
|
|
12
|
+
* Core cố tình KHÔNG phụ thuộc thư viện auth nào: nó chỉ gọi qua bridge này,
|
|
13
|
+
* được mount ở src/providers/index.tsx. Object bridge phải là hằng số suốt
|
|
14
|
+
* vòng đời app (nó chứa một hook) — đừng tạo mới trong component.
|
|
15
|
+
*/
|
|
16
|
+
export const authClient = createAuthClient({
|
|
17
|
+
basePath: "/api/better-auth",
|
|
18
|
+
plugins: [customSessionClient<ReturnType<typeof getAuth>>()],
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const authBridgeClient: AuthBridgeClient = {
|
|
22
|
+
useSession: () => {
|
|
23
|
+
const s = authClient.useSession()
|
|
24
|
+
return {
|
|
25
|
+
data: s.data ? { user: s.data.user, expires: s.data.session?.expiresAt } : null,
|
|
26
|
+
status: s.isPending
|
|
27
|
+
? "loading"
|
|
28
|
+
: s.data
|
|
29
|
+
? "authenticated"
|
|
30
|
+
: "unauthenticated",
|
|
31
|
+
update: async () => {
|
|
32
|
+
await s.refetch()
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
signInWithCredentials: async ({ email, password }) => {
|
|
37
|
+
const { error } = await authClient.signIn.email({ email, password })
|
|
38
|
+
if (!error) return { error: null }
|
|
39
|
+
// Rate-limit của Better Auth trả 429 với message tiếng Anh — dịch cho người dùng.
|
|
40
|
+
if (error.status === 429) {
|
|
41
|
+
return {
|
|
42
|
+
error: "Bạn đã thử đăng nhập quá nhiều lần. Vui lòng đợi khoảng 1 phút rồi thử lại.",
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { error: error.message ?? "Đăng nhập thất bại" }
|
|
46
|
+
},
|
|
47
|
+
signOut: async (options) => {
|
|
48
|
+
await authClient.signOut()
|
|
49
|
+
if (typeof window !== "undefined") {
|
|
50
|
+
// Tab (URL + bộ lọc) sống trong sessionStorage qua cả hard reload — dọn
|
|
51
|
+
// khi đăng xuất để người đăng nhập sau không thấy tab của người trước.
|
|
52
|
+
sessionStorage.removeItem("tab-navigation-state")
|
|
53
|
+
sessionStorage.removeItem("tab-content-cache")
|
|
54
|
+
if (options?.callbackUrl) window.location.href = options.callbackUrl
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cache } from "react"
|
|
2
|
+
import { headers } from "next/headers"
|
|
3
|
+
|
|
4
|
+
import type { Session } from "@/types/session"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ⚠️ CHỈ DEV: bỏ qua đăng nhập để dựng vỏ app trước khi có tài khoản thật.
|
|
8
|
+
* Guard `NODE_ENV !== "production"` khiến biến này vô hiệu trên bản build
|
|
9
|
+
* production dù có set env — đừng gỡ guard đó.
|
|
10
|
+
*/
|
|
11
|
+
const BYPASS_AUTH =
|
|
12
|
+
process.env.NODE_ENV !== "production" &&
|
|
13
|
+
(process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1")
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* getSession — nguồn session DUY NHẤT phía server. Mọi route/page gọi hàm này,
|
|
17
|
+
* đừng gọi thẳng `getAuth().api.getSession` chỗ khác.
|
|
18
|
+
*
|
|
19
|
+
* `cache()` của React gom mọi lần gọi trong CÙNG một request về một truy vấn.
|
|
20
|
+
*/
|
|
21
|
+
export const getSession = cache(async (): Promise<Session | null> => {
|
|
22
|
+
if (BYPASS_AUTH) {
|
|
23
|
+
return {
|
|
24
|
+
user: {
|
|
25
|
+
id: "dev-user",
|
|
26
|
+
email: "dev@example.com",
|
|
27
|
+
name: "Dev User",
|
|
28
|
+
avatar: null,
|
|
29
|
+
status: "active",
|
|
30
|
+
roles: ["admin"], // vai trò admin ⇒ core mở toàn quyền, khỏi cần seed
|
|
31
|
+
branchId: null,
|
|
32
|
+
branches: [],
|
|
33
|
+
permissions: [],
|
|
34
|
+
},
|
|
35
|
+
expires: new Date(Date.now() + 30 * 864e5).toISOString(),
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Import ĐỘNG có chủ đích — đừng đổi thành static. Turbopack dev có race lúc
|
|
40
|
+
// cold compile: hai request đầu ép module này compile song song và binding
|
|
41
|
+
// tĩnh tới better-auth có thể đóng băng ở undefined ("getAuth is not a
|
|
42
|
+
// function" ở mọi API route, chỉ hết khi sửa chạm file). Import động phân
|
|
43
|
+
// giải lúc runtime nên miễn nhiễm; bản production đã nạp sẵn module nên
|
|
44
|
+
// await gần như bằng 0.
|
|
45
|
+
const { getAuth } = await import("@/lib/better-auth")
|
|
46
|
+
const result = await getAuth().api.getSession({ headers: await headers() })
|
|
47
|
+
if (!result?.user) return null
|
|
48
|
+
|
|
49
|
+
const expiresAt = result.session?.expiresAt
|
|
50
|
+
return {
|
|
51
|
+
user: result.user,
|
|
52
|
+
expires:
|
|
53
|
+
expiresAt instanceof Date ? expiresAt.toISOString() : String(expiresAt ?? ""),
|
|
54
|
+
} as unknown as Session
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
/** Dùng trong API route/server action khi bắt buộc phải có người dùng. */
|
|
58
|
+
export async function requireUser() {
|
|
59
|
+
const session = await getSession()
|
|
60
|
+
if (!session?.user?.id) throw new Error("Unauthorized")
|
|
61
|
+
return session.user
|
|
62
|
+
}
|