@goplusvn/core 0.1.100 → 0.1.102
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/CHANGELOG.md +82 -1
- package/package.json +1 -1
- package/src/crud/components/crud-page.tsx +11 -3
- package/src/ui/data-display/data-table/data-table.tsx +46 -37
- package/src/ui/feedback/index.tsx +22 -14
- package/src/ui/forms/multi-select.tsx +1 -2
- package/src/ui/primitives/__tests__/dialog-fixed-child.test.tsx +101 -0
- package/src/ui/primitives/combobox.tsx +7 -4
- package/src/ui/primitives/dialog.tsx +51 -30
- package/templates/starter-app/Dockerfile +10 -1
- package/templates/starter-app/README.md +33 -0
- package/templates/starter-app/eslint.config.mjs +6 -3
- package/templates/starter-app/next.config.mjs +3 -1
- package/templates/starter-app/package.json +3 -1
- package/templates/starter-app/prisma/seed.ts +6 -2
- package/templates/starter-app/prisma.config.ts +1 -0
- package/templates/starter-app/scripts/backup-db.sh +51 -0
- package/templates/starter-app/scripts/migration-new.mjs +13 -7
- package/templates/starter-app/scripts/restore-db.sh +147 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +1 -2
- package/templates/starter-app/src/app/api/branding/logo/route.ts +1 -1
- package/templates/starter-app/src/app/layout.tsx +6 -2
- package/templates/starter-app/src/app/manifest.ts +1 -1
- package/templates/starter-app/src/configs/permissions/index.ts +10 -4
- package/templates/starter-app/src/instrumentation.ts +6 -3
- package/templates/starter-app/src/lib/auth-client.ts +6 -3
- package/templates/starter-app/vitest.config.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,90 @@
|
|
|
1
|
+
## 0.1.102 — Template tự sao lưu và phục hồi được; bịt lỗ `.env` lọt vào gói npm
|
|
2
|
+
|
|
3
|
+
Ba lỗ trong `templates/starter-app`, cùng một hậu quả: khách nào dựng từ đây cũng thừa hưởng.
|
|
4
|
+
|
|
5
|
+
**1. Gói npm nuốt cả `.env` của máy publish.** `files` của package kéo NGUYÊN thư mục `templates/`,
|
|
6
|
+
mà thư mục đó lúc phát triển có `.env` thật, `.next/`, `.turbo/`, `node_modules/` — cả bốn đều không
|
|
7
|
+
được git theo dõi. Publish từ CI (checkout sạch) thì không sao, nên các bản đã lên registry LÀNH: đã
|
|
8
|
+
kiểm 0.1.83 / 0.1.93 / 0.1.101, không bản nào có `.env`. Nhưng publish từ máy lập trình viên thì
|
|
9
|
+
`npm pack` gói hết: đo được **2.112 file / 16,1 MB kèm `.env` chứa `BETTER_AUTH_SECRET`**, so với
|
|
10
|
+
730 file / 4,4 MB của bản trên registry. Thêm `.npmignore` để chặn tại chỗ thay vì dựa vào "CI luôn
|
|
11
|
+
sạch". Sau khi vá, danh sách file `npm pack` tại máy khớp **không sai một file nào** với 0.1.101.
|
|
12
|
+
|
|
13
|
+
**2. Image không có `pg_dump` lẫn `psql`.** Khách dựng từ template không có cả binary để lấy dữ liệu
|
|
14
|
+
của mình ra. Thêm `postgresql18-client` + `gzip` vào stage `base` (mọi stage kế thừa, kể cả `runner`).
|
|
15
|
+
Bản 18 vì pg_dump TỪ CHỐI dump server có major mới hơn client — client mới hơn server thì vẫn chạy,
|
|
16
|
+
nên 18 an toàn cả hai chiều. `gzip` cài thật chứ không tin busybox: busybox có `gzip -t` nhưng KHÔNG
|
|
17
|
+
có `gzip -l`, mà kịch bản phục hồi cần cả hai.
|
|
18
|
+
|
|
19
|
+
**3. Có đường sao lưu thì phải có đường phục hồi.** Thêm `scripts/backup-db.sh` +
|
|
20
|
+
`scripts/restore-db.sh` (`pnpm db:backup` / `pnpm db:restore`). Cả hai nhận thẳng URI Postgres, KHÔNG
|
|
21
|
+
tách user/pass/host bằng tay — đã diễn tập với mật khẩu `p@ss:word` (có cả `@` lẫn `:`), thứ làm vỡ
|
|
22
|
+
mọi cách tách chuỗi.
|
|
23
|
+
|
|
24
|
+
Ba cái bẫy `restore-db.sh` chặn sẵn, mỗi cái đều đã dựng lại và xem nó nổ:
|
|
25
|
+
|
|
26
|
+
- `psql` mặc định chạy tiếp qua lỗi rồi thoát 0 ⇒ dump hỏng nửa chừng vẫn báo "thành công".
|
|
27
|
+
Luôn `-v ON_ERROR_STOP=1 --single-transaction`.
|
|
28
|
+
- `gzip -t` KHÔNG bắt được dump cụt: pg_dump chết giữa chừng vẫn cho ra file nén CRC đúng. Phép kiểm
|
|
29
|
+
thật là dòng cuối `PostgreSQL database dump complete`.
|
|
30
|
+
- Đích phải khai bằng `--to`; trùng `DATABASE_URL` của app thì phải có `--yes-production`; đích đã có
|
|
31
|
+
bảng thì phải có `--drop`.
|
|
32
|
+
|
|
33
|
+
Diễn tập trọn vòng (Postgres 18, dữ liệu có tiếng Việt có dấu, nháy đơn lồng, `\`, tab, xuống dòng,
|
|
34
|
+
jsonb, mảng text NOT NULL, chuỗi rỗng vs NULL): md5 nội dung bảng ở nguồn và ở bản phục hồi **giống
|
|
35
|
+
hệt**, 8/8 nhánh của script cho đúng kết quả mong đợi.
|
|
36
|
+
|
|
37
|
+
**4. Ghim `@goerp/core` trong template.** `^0.1.101` → `0.1.101`. Caret trên gói 0.x nhận MỌI bản
|
|
38
|
+
0.1.*, nên hai khách scaffold cách nhau một tháng chạy hai bản core khác nhau mà không có gì báo.
|
|
39
|
+
Bằng chứng ngay trong repo: lockfile của template ghi `specifier: ...@^0.1.71` và giải ra **0.1.83**
|
|
40
|
+
trong khi package.json nói 0.1.101 — lệch 18 bản, không ai thấy.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 0.1.101 — Bỏ transform khỏi DialogContent (điều kiện sống của `portal=false`) & khung chờ bảng không còn dựng 1.000 dòng
|
|
45
|
+
|
|
46
|
+
0.1.100 cho Combobox/MultiSelect render inline (`portal=false`) để FocusScope của Dialog không cướp
|
|
47
|
+
focus ô tìm kiếm. Nhưng `DialogContent` khi đó vẫn canh giữa bằng `left-1/2 top-1/2
|
|
48
|
+
translate-x-[-50%] translate-y-[-50%]`, mà `transform` biến phần tử thành CONTAINING BLOCK của mọi
|
|
49
|
+
con `position: fixed` — panel của Radix popper (chạy `strategy: "fixed"`) vừa đổi hệ toạ độ vừa bị
|
|
50
|
+
`overflow-y-auto` của hộp thoại (và `overflow-hidden` mà CrudDialog truyền vào) CẮT SẠCH. Đo Chromium
|
|
51
|
+
với cùng toạ độ hình học: `elementFromPoint` trả về `<body>` khi có transform, trả về đúng panel khi
|
|
52
|
+
không có. Ảnh hưởng nặng nhất là form CRUD trên desktop — hộp thoại ngắn, combo ở field cuối mở
|
|
53
|
+
xuống dưới mép hộp là mất hút. Trên mobile `CrudDialog` cao `h-[100vh]` nên panel thường lọt trong
|
|
54
|
+
hộp, không lộ lỗi.
|
|
55
|
+
|
|
56
|
+
**Đổi**
|
|
57
|
+
|
|
58
|
+
- `ui/primitives/dialog.tsx` — canh giữa bằng lớp bọc `fixed inset-0 grid place-items-center`
|
|
59
|
+
(`pointer-events-none`, content `pointer-events-auto`), `DialogContent` không còn `fixed`/`left-1/2`/
|
|
60
|
+
`top-1/2`/`translate-*`. Animation rút về `fade` + `zoom` (hai class `slide-*-1/2` cũ chỉ có nghĩa
|
|
61
|
+
khi đi kèm phép canh giữa bằng translate). Call site muốn lệch tâm thì dùng `self-*`/`justify-self-*`
|
|
62
|
+
của lưới — TUYỆT ĐỐI không dùng `translate-*`, kể cả `translate-y-0` (đã đo: `translate: 0 0` và
|
|
63
|
+
`will-change: transform` cũng tạo containing block).
|
|
64
|
+
- `ui/primitives/__tests__/dialog-fixed-child.test.tsx` — MỚI. Khoá lại: `DialogContent` và lớp bọc
|
|
65
|
+
không mang class transform nào ở trạng thái tĩnh.
|
|
66
|
+
- `ui/primitives/combobox.tsx` — cập nhật comment đã lạc hậu từ 0.1.100 (vẫn ghi "panel đi qua Portal").
|
|
67
|
+
- `ui/forms/multi-select.tsx` — `normalizeVietnamese` thêm `.trim()` cho khớp bản trong `combobox.tsx`.
|
|
68
|
+
|
|
69
|
+
**Bảng CRUD 1.000 dòng: có báo tải, không đứng hình**
|
|
70
|
+
|
|
71
|
+
Chọn "1.000 dòng" thì `DataTable` dựng khung chờ đúng 1.000 hàng skeleton × số cột (hàng vạn node
|
|
72
|
+
`animate-pulse`), vẽ ra rồi vứt đi ngay khi data về, kế đó dựng tiếp 1.000 hàng thật trong MỘT lượt
|
|
73
|
+
render đồng bộ — main thread đứng vài giây, người dùng tưởng app treo.
|
|
74
|
+
|
|
75
|
+
- `ui/data-display/data-table/data-table.tsx` — số dòng khung chờ kẹp trần 12 (`SKELETON_MAX_ROWS`)
|
|
76
|
+
thay vì bám `pageSize`.
|
|
77
|
+
- `crud/components/crud-page.tsx` — `setData` chạy trong `useTransition`: React chia nhỏ lượt dựng
|
|
78
|
+
bảng lớn nên trang vẫn bấm/cuộn được, và `isRenderPending` giữ khung chờ hiển thị cho tới khi bảng
|
|
79
|
+
sẵn sàng.
|
|
80
|
+
|
|
1
81
|
## 0.1.100 — Cho phép cấu hình Inline Render (portal=false) cho Combobox & MultiSelect
|
|
2
82
|
|
|
3
83
|
Khi Combobox hoặc MultiSelect nằm trong Modal/Dialog/Sheet, Radix Portal ra `document.body` khiến
|
|
4
84
|
panel nằm ngoài DOM tree của Dialog cha, dẫn đến việc Radix Dialog gán `aria-hidden` và cướp focus
|
|
5
85
|
của ô tìm kiếm.
|
|
6
|
-
Bản này bổ sung prop `portal?: boolean` (mặc định `
|
|
86
|
+
Bản này bổ sung prop `portal?: boolean` trên `PopoverContent` (mặc định `true`), `Combobox` và `MultiSelect`
|
|
87
|
+
(mặc định `false`),
|
|
7
88
|
giúp panel render inline cùng DOM tree của Dialog/Sheet, giải quyết triệt để vấn đề mất focus trên cả Desktop và Mobile.
|
|
8
89
|
|
|
9
90
|
**Đổi**
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useMemo,
|
|
7
7
|
useRef,
|
|
8
8
|
useCallback,
|
|
9
|
+
useTransition,
|
|
9
10
|
} from "react";
|
|
10
11
|
import { Button } from "../../ui";
|
|
11
12
|
import { globalError } from "../../ui/feedback/error-dialog";
|
|
@@ -181,6 +182,13 @@ function CrudPageContent({
|
|
|
181
182
|
});
|
|
182
183
|
const [loading, setLoading] = useState(true);
|
|
183
184
|
const [tableLoading, setTableLoading] = useState(false); // Separate loading state for table only
|
|
185
|
+
// Dựng 1.000 hàng bảng là công việc NẶNG (mỗi hàng × mỗi cột một cell
|
|
186
|
+
// TanStack). Nếu `setData` chạy như cập nhật thường, React dựng cả cây trong
|
|
187
|
+
// MỘT lượt đồng bộ: trình duyệt không vẽ được frame nào, người dùng thấy app
|
|
188
|
+
// đứng hình và tưởng là treo. Bọc trong transition thì React chia nhỏ lượt
|
|
189
|
+
// dựng, trang vẫn cuộn/bấm được, và `isRenderPending` giữ khung chờ hiển thị
|
|
190
|
+
// cho tới lúc bảng thật sự sẵn sàng — chờ có báo, không phải chết máy.
|
|
191
|
+
const [isRenderPending, startRenderTransition] = useTransition();
|
|
184
192
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
185
193
|
const [editingRowId, setEditingRowId] = useState<string | null>(null);
|
|
186
194
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
@@ -261,7 +269,7 @@ function CrudPageContent({
|
|
|
261
269
|
|
|
262
270
|
// Only set data if this is still the latest request
|
|
263
271
|
if (requestId === currentRequestRef.current) {
|
|
264
|
-
setData(response);
|
|
272
|
+
startRenderTransition(() => setData(response));
|
|
265
273
|
}
|
|
266
274
|
} catch (error) {
|
|
267
275
|
// Ignore abort errors
|
|
@@ -849,7 +857,7 @@ function CrudPageContent({
|
|
|
849
857
|
{viewMode === "table" ? (
|
|
850
858
|
<CrudTable
|
|
851
859
|
data={data as CrudResponse<Record<string, unknown>>}
|
|
852
|
-
loading={loading || tableLoading}
|
|
860
|
+
loading={loading || tableLoading || isRenderPending}
|
|
853
861
|
onEdit={handleEdit}
|
|
854
862
|
onDelete={handleDelete}
|
|
855
863
|
onCustomAction={handleCustomAction}
|
|
@@ -868,7 +876,7 @@ function CrudPageContent({
|
|
|
868
876
|
config={config}
|
|
869
877
|
data={data as CrudResponse<Record<string, unknown>>}
|
|
870
878
|
permissions={permissions}
|
|
871
|
-
loading={loading || tableLoading}
|
|
879
|
+
loading={loading || tableLoading || isRenderPending}
|
|
872
880
|
onEdit={handleEdit}
|
|
873
881
|
onDelete={handleDelete}
|
|
874
882
|
onCustomAction={handleCustomAction}
|
|
@@ -153,7 +153,9 @@ export interface DataTableProps<TData> {
|
|
|
153
153
|
/**
|
|
154
154
|
* Callback when expanded state changes
|
|
155
155
|
*/
|
|
156
|
-
onExpandedChange?: (
|
|
156
|
+
onExpandedChange?: (
|
|
157
|
+
expanded: ExpandedState | ((old: ExpandedState) => ExpandedState),
|
|
158
|
+
) => void;
|
|
157
159
|
|
|
158
160
|
/**
|
|
159
161
|
* Render custom sub-component (expanded row content)
|
|
@@ -194,6 +196,9 @@ export interface DataTableProps<TData> {
|
|
|
194
196
|
// DataTable Component
|
|
195
197
|
// ============================================================================
|
|
196
198
|
|
|
199
|
+
// Trần số dòng khung chờ (xem chỗ dùng bên dưới).
|
|
200
|
+
const SKELETON_MAX_ROWS = 12;
|
|
201
|
+
|
|
197
202
|
export function DataTable<TData extends Record<string, unknown>>({
|
|
198
203
|
data,
|
|
199
204
|
columns: userColumns,
|
|
@@ -225,8 +230,9 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
225
230
|
}: DataTableProps<TData>) {
|
|
226
231
|
// Local state for expansion (used when uncontrolled)
|
|
227
232
|
const [internalExpanded, setInternalExpanded] = useState<ExpandedState>({});
|
|
228
|
-
|
|
229
|
-
const expanded =
|
|
233
|
+
|
|
234
|
+
const expanded =
|
|
235
|
+
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
|
230
236
|
const setExpanded = controlledOnExpandedChange || setInternalExpanded;
|
|
231
237
|
|
|
232
238
|
// Build final columns with selection and row number if enabled
|
|
@@ -491,7 +497,13 @@ export function DataTable<TData extends Record<string, unknown>>({
|
|
|
491
497
|
</tr>
|
|
492
498
|
<DataTableSkeleton
|
|
493
499
|
columns={userColumns.length}
|
|
494
|
-
|
|
500
|
+
// Số dòng KHUNG CHỜ phải kẹp trần, đừng bám pageSize.
|
|
501
|
+
// Chọn "1.000 dòng" thì bản cũ dựng 1.000 hàng skeleton ×
|
|
502
|
+
// số cột — hàng vạn node `animate-pulse` vẽ ra rồi vứt đi
|
|
503
|
+
// ngay khi data về, kế đó lại dựng tiếp 1.000 hàng thật:
|
|
504
|
+
// main thread đứng vài giây, người dùng tưởng app treo.
|
|
505
|
+
// Khung chờ chỉ cần phủ hết khung nhìn là đủ.
|
|
506
|
+
rows={Math.min(pagination?.pageSize ?? 10, SKELETON_MAX_ROWS)}
|
|
495
507
|
showCheckbox={enableRowSelection}
|
|
496
508
|
showRowNumber={enableRowNumber}
|
|
497
509
|
/>
|
|
@@ -642,44 +654,41 @@ function DataTableRowInner<TData>({
|
|
|
642
654
|
);
|
|
643
655
|
}
|
|
644
656
|
|
|
645
|
-
const MemoizedTableRow = memo(
|
|
646
|
-
|
|
647
|
-
(
|
|
648
|
-
|
|
657
|
+
const MemoizedTableRow = memo(DataTableRowInner, (prevProps, nextProps) => {
|
|
658
|
+
// Basic checks
|
|
659
|
+
if (
|
|
660
|
+
prevProps.row.id !== nextProps.row.id ||
|
|
661
|
+
prevProps.isSelected !== nextProps.isSelected ||
|
|
662
|
+
prevProps.onRowClick !== nextProps.onRowClick ||
|
|
663
|
+
prevProps.visibleCellsCount !== nextProps.visibleCellsCount ||
|
|
664
|
+
prevProps.row.original !== nextProps.row.original ||
|
|
665
|
+
// cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
|
|
666
|
+
prevProps.cellClassName !== nextProps.cellClassName ||
|
|
667
|
+
// Kiểm tra trạng thái expand (dùng prop thay vì method của row để tránh mutable reference)
|
|
668
|
+
prevProps.isExpanded !== nextProps.isExpanded ||
|
|
669
|
+
// Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
|
|
670
|
+
!Object.is(prevProps.memoKey, nextProps.memoKey)
|
|
671
|
+
) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Since sizing could change without cells count changing
|
|
676
|
+
// We check if cell sizes changed
|
|
677
|
+
const prevCells = prevProps.row.getVisibleCells();
|
|
678
|
+
const nextCells = nextProps.row.getVisibleCells();
|
|
679
|
+
|
|
680
|
+
for (let i = 0; i < prevCells.length; i++) {
|
|
649
681
|
if (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
prevProps.visibleCellsCount !== nextProps.visibleCellsCount ||
|
|
654
|
-
prevProps.row.original !== nextProps.row.original ||
|
|
655
|
-
// cellClassName đổi phải re-render (trước đây bị bỏ sót — đổi class không ăn)
|
|
656
|
-
prevProps.cellClassName !== nextProps.cellClassName ||
|
|
657
|
-
// Kiểm tra trạng thái expand (dùng prop thay vì method của row để tránh mutable reference)
|
|
658
|
-
prevProps.isExpanded !== nextProps.isExpanded ||
|
|
659
|
-
// Key từ rowMemo(row.original): state ngoài row data mà cell phụ thuộc
|
|
660
|
-
!Object.is(prevProps.memoKey, nextProps.memoKey)
|
|
682
|
+
prevCells[i]?.column.getSize() !== nextCells[i]?.column.getSize() ||
|
|
683
|
+
prevCells[i]?.id !== nextCells[i]?.id ||
|
|
684
|
+
prevCells[i]?.column.id !== nextCells[i]?.column.id
|
|
661
685
|
) {
|
|
662
686
|
return false;
|
|
663
687
|
}
|
|
688
|
+
}
|
|
664
689
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
const prevCells = prevProps.row.getVisibleCells();
|
|
668
|
-
const nextCells = nextProps.row.getVisibleCells();
|
|
669
|
-
|
|
670
|
-
for (let i = 0; i < prevCells.length; i++) {
|
|
671
|
-
if (
|
|
672
|
-
prevCells[i]?.column.getSize() !== nextCells[i]?.column.getSize() ||
|
|
673
|
-
prevCells[i]?.id !== nextCells[i]?.id ||
|
|
674
|
-
prevCells[i]?.column.id !== nextCells[i]?.column.id
|
|
675
|
-
) {
|
|
676
|
-
return false;
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
return true;
|
|
681
|
-
},
|
|
682
|
-
) as <TData>(props: MemoizedTableRowProps<TData>) => ReactElement;
|
|
690
|
+
return true;
|
|
691
|
+
}) as <TData>(props: MemoizedTableRowProps<TData>) => ReactElement;
|
|
683
692
|
// The type cast ensures the component retains the generic type TData
|
|
684
693
|
// Without this, the component type would default to `any` or `unknown`.
|
|
685
694
|
// Adding displayName to a typecast component can cause a ts error, so we cast it as any to add the display name.
|
|
@@ -86,20 +86,28 @@ export function AlertDialogContent({
|
|
|
86
86
|
return (
|
|
87
87
|
<AlertDialogPortal>
|
|
88
88
|
<AlertDialogOverlay />
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
89
|
+
{/*
|
|
90
|
+
Canh giữa bằng lưới, KHÔNG bằng `translate-*` — cùng lý do với
|
|
91
|
+
`DialogContent` (xem `ui/primitives/dialog.tsx`): transform biến hộp
|
|
92
|
+
thoại thành containing block của con `position: fixed`, làm panel
|
|
93
|
+
Popover/Select bên trong bị `overflow-*` của chính hộp thoại cắt mất.
|
|
94
|
+
*/}
|
|
95
|
+
<div className="pointer-events-none fixed inset-0 z-50 grid place-items-center">
|
|
96
|
+
<AlertDialogPrimitive.Content
|
|
97
|
+
data-slot="alert-dialog-content"
|
|
98
|
+
className={cn(
|
|
99
|
+
"pointer-events-auto relative z-50 w-[calc(100%_-_2rem)] sm:w-full grid bg-background gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-lg",
|
|
100
|
+
// KHÔNG để `sm:p-6` / `sm:max-w-lg` làm mặc định: twMerge chỉ khử xung
|
|
101
|
+
// đột trong cùng variant, nên trang truyền `p-0` / `max-w-sm` sẽ bị
|
|
102
|
+
// class `sm:` đè ngược ở ≥640px (đúng lỗi 0.1.84 của DialogContent).
|
|
103
|
+
// Cùng lý do với DialogContent: bị neo giữa màn hình nên phải chặn
|
|
104
|
+
// chiều cao, không thì mô tả dài sẽ tràn ra ngoài và không cuộn được.
|
|
105
|
+
"max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
|
|
106
|
+
className,
|
|
107
|
+
)}
|
|
108
|
+
{...props}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
103
111
|
</AlertDialogPortal>
|
|
104
112
|
);
|
|
105
113
|
}
|
|
@@ -151,6 +151,7 @@ const normalizeVietnamese = (str: string) =>
|
|
|
151
151
|
.replace(/[\u0300-\u036f]/g, "")
|
|
152
152
|
.replace(/[đĐ]/g, (m) => (m === "đ" ? "d" : "D"))
|
|
153
153
|
.toLowerCase()
|
|
154
|
+
.trim()
|
|
154
155
|
: "";
|
|
155
156
|
|
|
156
157
|
const normalizeVietnameseChar = (char: string) => {
|
|
@@ -466,8 +467,6 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
466
467
|
}
|
|
467
468
|
}, [highlightedIndex, isVirtual, open]);
|
|
468
469
|
|
|
469
|
-
|
|
470
|
-
|
|
471
470
|
// Click-ra-ngoài + Escape do DismissableLayer của Radix Popover lo (panel
|
|
472
471
|
// đã đi qua Portal). Không tự nghe document nữa: hai lớp cùng đóng dễ
|
|
473
472
|
// đá nhau, và bộ nghe cũ còn phải chừa ngoại lệ cho dialog.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
|
|
4
|
+
import { Dialog, DialogContent, DialogTitle } from "../dialog";
|
|
5
|
+
import {
|
|
6
|
+
AlertDialog,
|
|
7
|
+
AlertDialogContent,
|
|
8
|
+
AlertDialogTitle,
|
|
9
|
+
} from "../../feedback";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DialogContent KHÔNG được mang transform.
|
|
13
|
+
*
|
|
14
|
+
* Panel của Popover/Combobox/MultiSelect/Select chạy `position: fixed`
|
|
15
|
+
* (Radix popper dùng `strategy: "fixed"`). Một tổ tiên có `transform` /
|
|
16
|
+
* `translate` / `will-change: transform` sẽ trở thành CONTAINING BLOCK của
|
|
17
|
+
* con `fixed` đó — kéo theo hai hậu quả: toạ độ đổi hệ quy chiếu, và
|
|
18
|
+
* `overflow-*` của hộp thoại CẮT panel. Dropdown mở quá mép hộp thoại thì
|
|
19
|
+
* biến mất sạch (đo Chromium: `elementFromPoint` trả về `<body>`).
|
|
20
|
+
*
|
|
21
|
+
* Vì Combobox/MultiSelect render inline (`portal={false}`, để FocusScope của
|
|
22
|
+
* Dialog không cướp focus ô tìm kiếm), điều kiện "hộp thoại không tạo
|
|
23
|
+
* containing block" là thứ DUY NHẤT giữ cho panel hiện đủ. Bản cũ canh giữa
|
|
24
|
+
* bằng `left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%]` nên vi phạm;
|
|
25
|
+
* bản mới canh giữa bằng lưới ở lớp bọc.
|
|
26
|
+
*
|
|
27
|
+
* Test này khoá lại điều đó — kể cả một class trông vô hại như
|
|
28
|
+
* `translate-y-0` cũng tạo containing block (đã đo).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Class Tailwind tạo transform. Chỉ soi token KHÔNG có variant prefix
|
|
32
|
+
// (`data-[state=open]:zoom-in-95` chỉ chạy lúc animate nên vô hại).
|
|
33
|
+
const TRANSFORM_UTILITY =
|
|
34
|
+
/^-?(translate|rotate|scale|skew)-|^transform$|^will-change-transform$/;
|
|
35
|
+
|
|
36
|
+
function baseTransformClasses(el: Element): string[] {
|
|
37
|
+
return el.className
|
|
38
|
+
.split(/\s+/)
|
|
39
|
+
.filter((c) => c && !c.includes(":"))
|
|
40
|
+
.filter((c) => TRANSFORM_UTILITY.test(c));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("DialogContent", () => {
|
|
44
|
+
it("không mang class transform nào ở trạng thái tĩnh", () => {
|
|
45
|
+
render(
|
|
46
|
+
<Dialog open>
|
|
47
|
+
<DialogContent>
|
|
48
|
+
<DialogTitle>Tiêu đề</DialogTitle>
|
|
49
|
+
</DialogContent>
|
|
50
|
+
</Dialog>,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const content = screen.getByRole("dialog");
|
|
54
|
+
expect(baseTransformClasses(content)).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("lớp bọc canh giữa cũng không mang transform", () => {
|
|
58
|
+
render(
|
|
59
|
+
<Dialog open>
|
|
60
|
+
<DialogContent>
|
|
61
|
+
<DialogTitle>Tiêu đề</DialogTitle>
|
|
62
|
+
</DialogContent>
|
|
63
|
+
</Dialog>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const wrapper = screen.getByRole("dialog").parentElement!;
|
|
67
|
+
expect(wrapper.className).toContain("grid");
|
|
68
|
+
expect(wrapper.className).toContain("place-items-center");
|
|
69
|
+
expect(baseTransformClasses(wrapper)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("className của caller không được lén đưa transform vào", () => {
|
|
73
|
+
// Không chặn được ở runtime, nhưng khoá lại được ĐỊNH DẠNG: mặc định của
|
|
74
|
+
// core phải sạch để lỗi (nếu có) chỉ tới từ đúng một chỗ — call site.
|
|
75
|
+
render(
|
|
76
|
+
<Dialog open>
|
|
77
|
+
<DialogContent className="max-w-2xl p-0">
|
|
78
|
+
<DialogTitle>Tiêu đề</DialogTitle>
|
|
79
|
+
</DialogContent>
|
|
80
|
+
</Dialog>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(baseTransformClasses(screen.getByRole("dialog"))).toEqual([]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("AlertDialogContent", () => {
|
|
88
|
+
it("không mang class transform nào ở trạng thái tĩnh", () => {
|
|
89
|
+
render(
|
|
90
|
+
<AlertDialog open>
|
|
91
|
+
<AlertDialogContent>
|
|
92
|
+
<AlertDialogTitle>Xoá?</AlertDialogTitle>
|
|
93
|
+
</AlertDialogContent>
|
|
94
|
+
</AlertDialog>,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const content = screen.getByRole("alertdialog");
|
|
98
|
+
expect(baseTransformClasses(content)).toEqual([]);
|
|
99
|
+
expect(content.parentElement!.className).toContain("place-items-center");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -184,10 +184,13 @@ export function Combobox({
|
|
|
184
184
|
</PopoverTrigger>
|
|
185
185
|
|
|
186
186
|
{/*
|
|
187
|
-
Panel
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
Panel render INLINE (`portal={false}` mặc định): nằm cùng DOM tree với
|
|
188
|
+
Dialog/Sheet cha nên FocusScope của Radix không cướp focus ô tìm kiếm.
|
|
189
|
+
Đổi lại, nó CHỈ hiện đủ khi hộp thoại cha không tạo containing block —
|
|
190
|
+
`DialogContent` của core vì thế đã bỏ sạch transform (xem `dialog.tsx`
|
|
191
|
+
+ `__tests__/dialog-fixed-child.test.tsx`). Panel vẫn là `position:
|
|
192
|
+
fixed` của Radix popper, tự lật lên trên khi thiếu chỗ bên dưới
|
|
193
|
+
(`avoidCollisions` mặc định).
|
|
191
194
|
- `--radix-popover-trigger-width`: panel rộng đúng bằng ô trigger.
|
|
192
195
|
- `--radix-popover-content-available-height`: trần chiều cao theo chỗ
|
|
193
196
|
trống thực tế, KHÔNG bao giờ tràn khỏi màn hình.
|
|
@@ -35,36 +35,57 @@ const DialogContent = React.forwardRef<
|
|
|
35
35
|
>(({ className, children, ...props }, ref) => {
|
|
36
36
|
useReleaseStuckBodyLock();
|
|
37
37
|
return (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
38
|
+
<DialogPortal>
|
|
39
|
+
<DialogOverlay />
|
|
40
|
+
{/*
|
|
41
|
+
Lớp canh giữa. CỐ Ý không còn `left-1/2 top-1/2 translate-x-[-50%]
|
|
42
|
+
translate-y-[-50%]` trên chính DialogContent.
|
|
43
|
+
|
|
44
|
+
`transform` biến phần tử thành CONTAINING BLOCK của mọi con
|
|
45
|
+
`position: fixed`. Panel của Popover/Combobox/Select (Radix popper chạy
|
|
46
|
+
`strategy: "fixed"`) vì thế vừa đổi hệ toạ độ, vừa BỊ CẮT bởi
|
|
47
|
+
`overflow-y-auto` ngay bên dưới — và bởi `overflow-hidden` mà CrudDialog
|
|
48
|
+
truyền vào. Dropdown mở quá mép hộp thoại thì biến mất SẠCH, không phải
|
|
49
|
+
cắt một nửa: đo Chromium (cùng toạ độ hình học) `elementFromPoint` trả
|
|
50
|
+
về `<body>` khi có transform, trả về đúng panel khi không có.
|
|
51
|
+
|
|
52
|
+
Grid centering không tạo containing block, nên con `fixed` lại neo theo
|
|
53
|
+
viewport và thoát khỏi mọi `overflow-*` của hộp thoại. Đây là điều kiện
|
|
54
|
+
để Combobox/MultiSelect render inline (`portal={false}` — cần thiết để
|
|
55
|
+
FocusScope của Dialog không cướp focus ô tìm kiếm) mà vẫn hiện đủ.
|
|
56
|
+
|
|
57
|
+
Khoá lại trong `__tests__/dialog-fixed-child.test.tsx`.
|
|
58
|
+
*/}
|
|
59
|
+
<div className="pointer-events-none fixed inset-0 z-50 grid place-items-center">
|
|
60
|
+
<DialogPrimitive.Content
|
|
61
|
+
ref={ref}
|
|
62
|
+
className={cn(
|
|
63
|
+
"pointer-events-auto relative z-50 grid gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
|
|
64
|
+
// Hộp thoại canh giữa theo cả hai chiều, nên nếu KHÔNG chặn chiều
|
|
65
|
+
// cao thì nội dung cao hơn màn hình sẽ tràn ra CẢ trên lẫn dưới và
|
|
66
|
+
// không cuộn được — hỏng trên điện thoại và trên tablet xoay ngang.
|
|
67
|
+
// Đây là mặc định: trang nào tự khai `max-h-*` / `overflow-*` thì
|
|
68
|
+
// class của trang đứng sau trong cn() nên vẫn thắng.
|
|
69
|
+
"max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
|
|
70
|
+
// Bề rộng: chừa 1rem mỗi bên trên màn hẹp thay vì dán sát mép.
|
|
71
|
+
// CẢNH BÁO (đã dính 0.1.84): mọi class mặc định ở đây mà trang có thể
|
|
72
|
+
// muốn ghi đè PHẢI để dạng KHÔNG prefix. `cn()` = twMerge chỉ khử xung
|
|
73
|
+
// đột trong CÙNG một variant — `sm:max-w-lg`/`sm:p-6` sẽ SỐNG SÓT khi
|
|
74
|
+
// trang truyền `max-w-2xl`/`p-0`, rồi thắng ở ≥640px vì nằm trong media
|
|
75
|
+
// query. Đó chính là lỗi bóp CrudDialog còn 512px + padding kép.
|
|
76
|
+
"w-[calc(100%_-_2rem)] sm:w-full max-w-lg",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
{...props}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
|
83
|
+
<X className="h-4 w-4" />
|
|
84
|
+
<span className="sr-only">Close</span>
|
|
85
|
+
</DialogPrimitive.Close>
|
|
86
|
+
</DialogPrimitive.Content>
|
|
87
|
+
</div>
|
|
88
|
+
</DialogPortal>
|
|
68
89
|
);
|
|
69
90
|
});
|
|
70
91
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
@@ -16,7 +16,16 @@ RUN npm install -g pnpm@10.8.1
|
|
|
16
16
|
# tzdata: cấp /usr/share/zoneinfo để biến TZ ở stage runner có tác dụng. Thiếu
|
|
17
17
|
# nó Alpine đứng nguyên UTC, và mọi phép "hôm nay" tính phía server lệch một
|
|
18
18
|
# ngày trong khung 00:00–07:00 giờ VN.
|
|
19
|
-
|
|
19
|
+
# postgresql18-client + gzip: để app SAO LƯU VÀ PHỤC HỒI ĐƯỢC CHÍNH NÓ. Thiếu
|
|
20
|
+
# hai gói này thì trong image không có `pg_dump` lẫn `psql` — khách dựng từ
|
|
21
|
+
# template không có cả binary để lấy dữ liệu của mình ra, và không có đường
|
|
22
|
+
# nào bung một bản sao lưu trở lại.
|
|
23
|
+
# - Bản 18 khớp Postgres 18 đang chạy: pg_dump TỪ CHỐI dump một server có
|
|
24
|
+
# major MỚI HƠN client. Client mới hơn server thì vẫn chạy được, nên 18 là
|
|
25
|
+
# mốc an toàn; hạ xuống 17 là hỏng ngay khi khách nâng lên 18.
|
|
26
|
+
# - `gzip` phải cài THẬT, đừng tin busybox: busybox có `gzip -t` nhưng KHÔNG
|
|
27
|
+
# có `gzip -l`, mà kịch bản phục hồi cần cả hai.
|
|
28
|
+
RUN apk add --no-cache libc6-compat openssl tzdata postgresql18-client gzip
|
|
20
29
|
|
|
21
30
|
# ── Stage 2: deps ────────────────────────────────────────────────────────────
|
|
22
31
|
FROM base AS deps
|
|
@@ -119,6 +119,8 @@ Feature đã cắm sẵn trong template: `audit-logs`, `background-tasks`,
|
|
|
119
119
|
| `pnpm prisma:deploy` | Áp migration đang chờ. **Cách duy nhất** đụng vào DB có dữ liệu thật. |
|
|
120
120
|
| `pnpm migration:new <ten>` | Sinh migration mới từ diff schema (shadow DB tự tạo/xoá — Prisma 7 đã bỏ `--shadow-database-url`). |
|
|
121
121
|
| `pnpm seed` | Vai trò + tài khoản admin + dữ liệu mẫu. Chạy TRƯỚC `rbac-sync`. |
|
|
122
|
+
| `pnpm db:backup` | Sao lưu ra `./backups/*.sql.gz` (xem §Sao lưu & phục hồi). |
|
|
123
|
+
| `pnpm db:restore --check-only <file>` | Kiểm một bản sao lưu, không đụng DB nào. |
|
|
122
124
|
|
|
123
125
|
---
|
|
124
126
|
|
|
@@ -133,6 +135,37 @@ Cần `pnpm-lock.yaml` trong build context (`pnpm install` sinh ra ở lần cà
|
|
|
133
135
|
|
|
134
136
|
---
|
|
135
137
|
|
|
138
|
+
## Sao lưu & phục hồi
|
|
139
|
+
|
|
140
|
+
Hai script đối xứng trong `scripts/`. **Đừng dùng cái này mà bỏ cái kia** — một
|
|
141
|
+
bản sao lưu chưa từng thử phục hồi thì không phải bản sao lưu, đó là một lời hứa.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
pnpm db:backup # → ./backups/<db>_backup_<ts>.sql.gz
|
|
145
|
+
BACKUP_DIR=/opt/backups pnpm db:backup # cron: 0 2 * * * cd /app && ./scripts/backup-db.sh
|
|
146
|
+
|
|
147
|
+
# rẻ nhất, không đụng database nào — chạy MỖI THÁNG một lần:
|
|
148
|
+
./scripts/restore-db.sh --check-only backups/<bản mới nhất>.sql.gz
|
|
149
|
+
|
|
150
|
+
# đầy đủ: dựng lại vào một database nháp
|
|
151
|
+
./scripts/restore-db.sh --drop --to 'postgresql://.../app_thu' backups/<...>.sql.gz
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Cả hai nhận thẳng URI của Postgres (`DATABASE_URL`), không tách user/pass/host
|
|
155
|
+
bằng tay — tách tay là chỗ vỡ kinh điển khi mật khẩu có `@` hoặc `:`.
|
|
156
|
+
|
|
157
|
+
Image đã có sẵn `postgresql18-client` + `gzip` cho hai script này.
|
|
158
|
+
|
|
159
|
+
### Ba cái bẫy script chặn sẵn
|
|
160
|
+
|
|
161
|
+
| Bẫy | Vì sao nó im lặng | Chặn bằng |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `psql` chạy tiếp qua lỗi rồi thoát 0 | dump hỏng nửa chừng vẫn báo "phục hồi thành công", DB thiếu bảng mà không ai biết | `-v ON_ERROR_STOP=1 --single-transaction` |
|
|
164
|
+
| `gzip -t` **không** bắt được dump cụt | pg_dump chết giữa chừng vẫn cho ra file nén CRC đúng | kiểm dòng cuối `PostgreSQL database dump complete` |
|
|
165
|
+
| phục hồi đè lên DB đang chạy | — | đích phải khai bằng `--to`; trùng `DATABASE_URL` thì phải có `--yes-production`; đích đã có bảng thì phải có `--drop` |
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
136
169
|
## Bẫy đã gặp
|
|
137
170
|
|
|
138
171
|
- Sau khi đổi schema → **restart `next dev`**. Đừng `rm -rf .next` khi dev đang chạy.
|
|
@@ -4,8 +4,8 @@ import { fileURLToPath } from "node:url"
|
|
|
4
4
|
|
|
5
5
|
import { includeIgnoreFile } from "@eslint/compat"
|
|
6
6
|
import js from "@eslint/js"
|
|
7
|
-
import nextPlugin from "@next/eslint-plugin-next"
|
|
8
7
|
import goerp from "@goerp/core/eslint"
|
|
8
|
+
import nextPlugin from "@next/eslint-plugin-next"
|
|
9
9
|
import tseslint from "@typescript-eslint/eslint-plugin"
|
|
10
10
|
import tsParser from "@typescript-eslint/parser"
|
|
11
11
|
import importPlugin from "eslint-plugin-import"
|
|
@@ -15,7 +15,9 @@ import reactHooksPlugin from "eslint-plugin-react-hooks"
|
|
|
15
15
|
import globals from "globals"
|
|
16
16
|
|
|
17
17
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
18
|
-
const
|
|
18
|
+
const gitignoreFile = existsSync(resolve(__dirname, ".gitignore"))
|
|
19
|
+
? resolve(__dirname, ".gitignore")
|
|
20
|
+
: resolve(__dirname, "gitignore")
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Cấu hình lint của app. Mốc cần giữ: **0 lỗi**.
|
|
@@ -30,7 +32,8 @@ const gitignorePath = resolve(__dirname, ".gitignore")
|
|
|
30
32
|
* và `pnpm guardrails` lo.
|
|
31
33
|
*/
|
|
32
34
|
const eslintConfig = [
|
|
33
|
-
...(existsSync(
|
|
35
|
+
...(existsSync(gitignoreFile) ? [includeIgnoreFile(gitignoreFile)] : []),
|
|
36
|
+
{ ignores: [".next/**", "node_modules/**", "dist/**"] },
|
|
34
37
|
js.configs.recommended,
|
|
35
38
|
{
|
|
36
39
|
files: ["**/*.{js,jsx,ts,tsx,mjs,cjs}"],
|
|
@@ -18,7 +18,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
|
18
18
|
*/
|
|
19
19
|
const workspaceRoot = (() => {
|
|
20
20
|
try {
|
|
21
|
-
const real = fs.realpathSync(
|
|
21
|
+
const real = fs.realpathSync(
|
|
22
|
+
path.join(__dirname, "node_modules/@goerp/core")
|
|
23
|
+
)
|
|
22
24
|
if (real.startsWith(__dirname + path.sep)) return __dirname
|
|
23
25
|
let dir = path.dirname(__dirname)
|
|
24
26
|
while (dir !== path.dirname(dir)) {
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"prisma:generate": "prisma generate",
|
|
14
14
|
"prisma:deploy": "prisma migrate deploy",
|
|
15
15
|
"prisma:studio": "prisma studio",
|
|
16
|
+
"db:backup": "./scripts/backup-db.sh",
|
|
17
|
+
"db:restore": "./scripts/restore-db.sh",
|
|
16
18
|
"seed": "tsx prisma/seed.ts",
|
|
17
19
|
"rbac-sync": "tsx scripts/rbac-sync.ts",
|
|
18
20
|
"goerp-features": "goerp-features",
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
"prepare": "husky || true"
|
|
23
25
|
},
|
|
24
26
|
"dependencies": {
|
|
25
|
-
"@goerp/core": "npm:@goplusvn/core
|
|
27
|
+
"@goerp/core": "npm:@goplusvn/core@0.1.101",
|
|
26
28
|
"@hookform/resolvers": "3.9.0",
|
|
27
29
|
"@prisma/adapter-pg": "^7.0.0",
|
|
28
30
|
"@prisma/client": "^7.0.0",
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
import { PrismaPg } from "@prisma/adapter-pg"
|
|
17
17
|
import { PrismaClient } from "@prisma/client"
|
|
18
18
|
import bcrypt from "bcryptjs"
|
|
19
|
+
|
|
19
20
|
import "dotenv/config"
|
|
20
21
|
|
|
21
22
|
const connectionString = process.env.DATABASE_URL
|
|
22
|
-
if (!connectionString)
|
|
23
|
+
if (!connectionString)
|
|
24
|
+
throw new Error("DATABASE_URL chưa được set (xem .env.example)")
|
|
23
25
|
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) })
|
|
24
26
|
|
|
25
27
|
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@example.com"
|
|
@@ -118,7 +120,9 @@ async function main() {
|
|
|
118
120
|
}
|
|
119
121
|
console.log(`✓ ${departments.length} phòng ban mẫu`)
|
|
120
122
|
|
|
121
|
-
console.log(
|
|
123
|
+
console.log(
|
|
124
|
+
"\nTiếp theo: pnpm rbac-sync (nạp resource/action/quyền từ registry)"
|
|
125
|
+
)
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
# Sao lưu database của app — dùng cùng `DATABASE_URL` mà app đang chạy.
|
|
5
|
+
# ==============================================================================
|
|
6
|
+
# ./scripts/backup-db.sh # dùng DATABASE_URL trong môi trường
|
|
7
|
+
# BACKUP_DIR=/opt/backups ./scripts/backup-db.sh
|
|
8
|
+
# cron: 0 2 * * * cd /app && ./scripts/backup-db.sh >> /var/log/backup.log 2>&1
|
|
9
|
+
#
|
|
10
|
+
# Cặp đôi với `restore-db.sh`. ĐỪNG dùng cái này mà bỏ cái kia: một bản sao lưu
|
|
11
|
+
# chưa từng thử phục hồi thì không phải bản sao lưu, đó là một lời hứa.
|
|
12
|
+
# Mỗi tháng chạy `./scripts/restore-db.sh --check-only <bản mới nhất>` một lần.
|
|
13
|
+
#
|
|
14
|
+
# Image (Dockerfile) đã có sẵn `postgresql18-client` + `gzip` cho hai script này.
|
|
15
|
+
# ==============================================================================
|
|
16
|
+
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
DB_URL="${DATABASE_URL:-}"
|
|
20
|
+
if [ -z "$DB_URL" ]; then
|
|
21
|
+
echo "❌ Thiếu DATABASE_URL." >&2
|
|
22
|
+
exit 1
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# pg_dump nhận thẳng URI, nên KHÔNG cần tách user/pass/host bằng tay — tách tay
|
|
26
|
+
# là chỗ hỏng kinh điển khi mật khẩu có ký tự '@' hoặc ':'.
|
|
27
|
+
# Chỉ lấy tên database để đặt tên file: phần sau dấu '/' cuối, bỏ query string.
|
|
28
|
+
DB_NAME=$(printf '%s' "$DB_URL" | sed -e 's|?.*$||' -e 's|.*/||')
|
|
29
|
+
DB_NAME="${DB_NAME:-database}"
|
|
30
|
+
|
|
31
|
+
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
|
32
|
+
KEEP_DAYS="${KEEP_DAYS:-7}"
|
|
33
|
+
mkdir -p "$BACKUP_DIR"
|
|
34
|
+
|
|
35
|
+
DATE=$(date +"%Y-%m-%d_%H-%M-%S")
|
|
36
|
+
FILEPATH="$BACKUP_DIR/${DB_NAME}_backup_$DATE.sql.gz"
|
|
37
|
+
|
|
38
|
+
echo "[$(date)] Bắt đầu sao lưu '$DB_NAME'..."
|
|
39
|
+
|
|
40
|
+
# `pipefail` ở đầu file là thứ bắt được ca pg_dump chết giữa chừng: không có nó,
|
|
41
|
+
# mã thoát của cả pipe là mã của gzip (luôn 0) ⇒ dump hỏng vẫn báo "thành công".
|
|
42
|
+
if pg_dump "$DB_URL" | gzip > "$FILEPATH"; then
|
|
43
|
+
echo "[$(date)] ✅ Sao lưu xong:"
|
|
44
|
+
du -h "$FILEPATH"
|
|
45
|
+
echo "[$(date)] Dọn bản cũ hơn $KEEP_DAYS ngày..."
|
|
46
|
+
find "$BACKUP_DIR" -name "${DB_NAME}_backup_*.sql.gz" -type f -mtime "+$KEEP_DAYS" -exec rm {} \;
|
|
47
|
+
else
|
|
48
|
+
echo "[$(date)] ❌ Sao lưu THẤT BẠI." >&2
|
|
49
|
+
rm -f "$FILEPATH" # đừng để lại file cụt trông như một bản sao lưu tốt
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
@@ -17,6 +17,7 @@ import path from "node:path"
|
|
|
17
17
|
import process from "node:process"
|
|
18
18
|
|
|
19
19
|
import "dotenv/config"
|
|
20
|
+
|
|
20
21
|
import pg from "pg"
|
|
21
22
|
|
|
22
23
|
const name = process.argv[2]
|
|
@@ -34,11 +35,13 @@ if (!dbUrl) {
|
|
|
34
35
|
const url = new URL(dbUrl)
|
|
35
36
|
const mainDb = url.pathname.slice(1)
|
|
36
37
|
const shadowDb = `${mainDb}_shadow_migrate`
|
|
37
|
-
const shadowUrl =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
38
|
+
const shadowUrl =
|
|
39
|
+
process.env.SHADOW_DATABASE_URL ??
|
|
40
|
+
(() => {
|
|
41
|
+
const u = new URL(dbUrl)
|
|
42
|
+
u.pathname = `/${shadowDb}`
|
|
43
|
+
return u.toString()
|
|
44
|
+
})()
|
|
42
45
|
|
|
43
46
|
const adminUrl = (() => {
|
|
44
47
|
const u = new URL(dbUrl)
|
|
@@ -63,7 +66,10 @@ async function withShadow(fn) {
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
const run = (cmd, env = {}) =>
|
|
66
|
-
execSync(cmd, {
|
|
69
|
+
execSync(cmd, {
|
|
70
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
71
|
+
env: { ...process.env, ...env },
|
|
72
|
+
})
|
|
67
73
|
|
|
68
74
|
await withShadow(async () => {
|
|
69
75
|
console.log(`→ deploy migrations hiện có vào shadow (${shadowDb})…`)
|
|
@@ -72,7 +78,7 @@ await withShadow(async () => {
|
|
|
72
78
|
console.log("→ diff shadow ↔ prisma/schema…")
|
|
73
79
|
const sql = run(
|
|
74
80
|
"pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema --script",
|
|
75
|
-
{ DATABASE_URL: shadowUrl }
|
|
81
|
+
{ DATABASE_URL: shadowUrl }
|
|
76
82
|
).toString()
|
|
77
83
|
|
|
78
84
|
if (!sql.trim() || /This is an empty migration/.test(sql)) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# ==============================================================================
|
|
4
|
+
# Phục hồi database từ bản sao lưu của `backup-db.sh`.
|
|
5
|
+
# ==============================================================================
|
|
6
|
+
# # rẻ nhất — chỉ kiểm file, KHÔNG đụng database nào (chạy hàng tháng):
|
|
7
|
+
# ./scripts/restore-db.sh --check-only backups/app_backup_....sql.gz
|
|
8
|
+
#
|
|
9
|
+
# # dựng lại vào một database NHÁP để kiểm tra:
|
|
10
|
+
# ./scripts/restore-db.sh --drop \
|
|
11
|
+
# --to 'postgresql://admin:pass@localhost:5432/app_thu' \
|
|
12
|
+
# backups/app_backup_....sql.gz
|
|
13
|
+
#
|
|
14
|
+
# Đích lấy từ `--to`, hoặc biến RESTORE_URL. CỐ Ý không mặc định về
|
|
15
|
+
# DATABASE_URL: mặc định tiện tay ở đây nghĩa là một lần gõ nhầm sẽ ghi đè
|
|
16
|
+
# database đang chạy thật.
|
|
17
|
+
#
|
|
18
|
+
# BA CÁI BẪY script này chặn — cả ba đều đã dựng lại và xem nó nổ (30/08/2026):
|
|
19
|
+
#
|
|
20
|
+
# 1. `psql` KHÔNG dừng khi gặp lỗi. Mặc định nó chạy tiếp tới câu cuối rồi
|
|
21
|
+
# thoát 0 ⇒ bản dump hỏng nửa chừng vẫn "phục hồi thành công", database
|
|
22
|
+
# thiếu bảng mà không ai biết. Ở đây luôn có `-v ON_ERROR_STOP=1`.
|
|
23
|
+
# 2. `gzip -t` KHÔNG bắt được dump cụt. pg_dump chết giữa chừng vẫn cho ra
|
|
24
|
+
# file nén hợp lệ, CRC đúng. Phép kiểm thật là dòng cuối
|
|
25
|
+
# `PostgreSQL database dump complete`.
|
|
26
|
+
# 3. Phục hồi đè lên database đang có dữ liệu, hoặc đè lên chính DATABASE_URL
|
|
27
|
+
# của app. Cả hai đều bị từ chối trừ khi nói rõ `--drop` / `--yes-production`.
|
|
28
|
+
# ==============================================================================
|
|
29
|
+
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
CHECK_ONLY=0
|
|
33
|
+
DROP_FIRST=0
|
|
34
|
+
ALLOW_PROD=0
|
|
35
|
+
TARGET="${RESTORE_URL:-}"
|
|
36
|
+
ARCHIVE=""
|
|
37
|
+
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--check-only) CHECK_ONLY=1 ;;
|
|
41
|
+
--drop) DROP_FIRST=1 ;;
|
|
42
|
+
--yes-production) ALLOW_PROD=1 ;;
|
|
43
|
+
--to) shift; TARGET="${1:-}" ;;
|
|
44
|
+
-h|--help) sed -n '3,20p' "$0"; exit 0 ;;
|
|
45
|
+
-*) echo "❌ Tham số lạ: $1" >&2; exit 2 ;;
|
|
46
|
+
*)
|
|
47
|
+
if [ -n "$ARCHIVE" ]; then echo "❌ Chỉ nhận MỘT file sao lưu." >&2; exit 2; fi
|
|
48
|
+
ARCHIVE="$1" ;;
|
|
49
|
+
esac
|
|
50
|
+
shift
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then
|
|
54
|
+
echo "❌ Thiếu hoặc không thấy file sao lưu: ${ARCHIVE:-<trống>}" >&2
|
|
55
|
+
echo " Dùng: $0 [--check-only] [--drop] [--to <url>] <*.sql.gz>" >&2
|
|
56
|
+
exit 2
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# --- Bước 1: file còn nguyên không -------------------------------------------
|
|
60
|
+
echo "[$(date)] Kiểm tra $ARCHIVE ..."
|
|
61
|
+
if ! gzip -t "$ARCHIVE"; then
|
|
62
|
+
echo "[$(date)] ❌ File nén HỎNG — không phục hồi được từ file này." >&2
|
|
63
|
+
exit 1
|
|
64
|
+
fi
|
|
65
|
+
echo "[$(date)] ✅ File nén nguyên vẹn ($(gzip -l "$ARCHIVE" | awk 'NR==2 {print $2}') byte sau giải nén)"
|
|
66
|
+
|
|
67
|
+
if ! gunzip -c "$ARCHIVE" | tail -n 20 | grep -q "PostgreSQL database dump complete"; then
|
|
68
|
+
echo "[$(date)] ❌ Dump CỤT: thiếu dòng kết 'PostgreSQL database dump complete'." >&2
|
|
69
|
+
echo " File nén thì nguyên nhưng pg_dump đã chết giữa chừng. Đừng dùng." >&2
|
|
70
|
+
exit 1
|
|
71
|
+
fi
|
|
72
|
+
echo "[$(date)] ✅ Dump có dòng kết — pg_dump đã chạy trọn."
|
|
73
|
+
|
|
74
|
+
TABLES_IN_DUMP=$(gunzip -c "$ARCHIVE" | grep -c "^CREATE TABLE ")
|
|
75
|
+
echo "[$(date)] 📦 Trong bản dump: $TABLES_IN_DUMP bảng"
|
|
76
|
+
|
|
77
|
+
if [ "$CHECK_ONLY" -eq 1 ]; then
|
|
78
|
+
echo "[$(date)] --check-only: dừng ở đây, không đụng vào database nào."
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# --- Bước 2: đích ------------------------------------------------------------
|
|
83
|
+
if [ -z "$TARGET" ]; then
|
|
84
|
+
echo "[$(date)] ❌ Chưa nói phục hồi vào đâu. Thêm --to <url> hoặc đặt RESTORE_URL." >&2
|
|
85
|
+
exit 2
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
if [ -n "${DATABASE_URL:-}" ] && [ "$TARGET" = "$DATABASE_URL" ] && [ "$ALLOW_PROD" -eq 0 ]; then
|
|
89
|
+
echo "[$(date)] ❌ Đích trùng DATABASE_URL của app — đây là database đang chạy thật." >&2
|
|
90
|
+
echo " Phục hồi vào đó sẽ GHI ĐÈ dữ liệu hiện có." >&2
|
|
91
|
+
echo " Nếu đúng là việc bạn muốn (thảm hoạ thật, đã mất DB), thêm --yes-production." >&2
|
|
92
|
+
exit 1
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
DB_NAME=$(printf '%s' "$TARGET" | sed -e 's|?.*$||' -e 's|.*/||')
|
|
96
|
+
ADMIN_URL=$(printf '%s' "$TARGET" | sed -e "s|/${DB_NAME}\(?.*\)\{0,1\}$|/postgres|")
|
|
97
|
+
|
|
98
|
+
if ! psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -tAqc "select 1" >/dev/null 2>&1; then
|
|
99
|
+
echo "[$(date)] ❌ Không kết nối được tới máy chủ đích (database 'postgres')." >&2
|
|
100
|
+
exit 1
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
# --- Bước 3: đích phải trống --------------------------------------------------
|
|
104
|
+
EXISTS=$(psql "$ADMIN_URL" -tAqc "select 1 from pg_database where datname = '$DB_NAME'")
|
|
105
|
+
if [ "$EXISTS" = "1" ]; then
|
|
106
|
+
N=$(psql "$TARGET" -tAqc "select count(*) from information_schema.tables where table_schema='public'")
|
|
107
|
+
if [ "${N:-0}" -gt 0 ] && [ "$DROP_FIRST" -eq 0 ]; then
|
|
108
|
+
echo "[$(date)] ❌ Database '$DB_NAME' đã có $N bảng." >&2
|
|
109
|
+
echo " Phục hồi đè lên sẽ trộn dữ liệu cũ với dữ liệu trong bản sao lưu." >&2
|
|
110
|
+
echo " Chọn database khác, hoặc thêm --drop để XOÁ HẲN '$DB_NAME' rồi dựng lại." >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
if [ "$DROP_FIRST" -eq 1 ]; then
|
|
114
|
+
echo "[$(date)] ⚠️ --drop: xoá database '$DB_NAME' ($N bảng) rồi tạo lại..."
|
|
115
|
+
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -qc "drop database \"$DB_NAME\" with (force)" || exit 1
|
|
116
|
+
EXISTS=""
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
if [ "$EXISTS" != "1" ]; then
|
|
120
|
+
echo "[$(date)] Tạo database '$DB_NAME'..."
|
|
121
|
+
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 -qc "create database \"$DB_NAME\"" || exit 1
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
# --- Bước 4: phục hồi ---------------------------------------------------------
|
|
125
|
+
echo "[$(date)] Phục hồi vào '$DB_NAME'..."
|
|
126
|
+
LOG=$(mktemp)
|
|
127
|
+
if gunzip -c "$ARCHIVE" | psql "$TARGET" -v ON_ERROR_STOP=1 --single-transaction -q > "$LOG" 2>&1; then
|
|
128
|
+
echo "[$(date)] ✅ Phục hồi thành công."
|
|
129
|
+
else
|
|
130
|
+
echo "[$(date)] ❌ Phục hồi THẤT BẠI. Nhật ký:" >&2
|
|
131
|
+
tail -n 30 "$LOG" >&2
|
|
132
|
+
rm -f "$LOG"; exit 1
|
|
133
|
+
fi
|
|
134
|
+
rm -f "$LOG"
|
|
135
|
+
|
|
136
|
+
# --- Bước 5: đối chiếu --------------------------------------------------------
|
|
137
|
+
# Restore xong mà không đếm lại thì vẫn chỉ là lời hứa.
|
|
138
|
+
RESTORED=$(psql "$TARGET" -tAqc "select count(*) from information_schema.tables where table_schema='public' and table_type='BASE TABLE'")
|
|
139
|
+
echo "[$(date)] 📊 Bảng trong dump: $TABLES_IN_DUMP — bảng sau phục hồi: $RESTORED"
|
|
140
|
+
if [ "$RESTORED" != "$TABLES_IN_DUMP" ]; then
|
|
141
|
+
echo "[$(date)] ❌ LỆCH số bảng. Bản phục hồi này KHÔNG dùng được." >&2
|
|
142
|
+
exit 1
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
echo "[$(date)] 10 bảng nhiều dòng nhất:"
|
|
146
|
+
psql "$TARGET" -c "select relname as bang, n_live_tup as so_dong from pg_stat_user_tables order by n_live_tup desc limit 10"
|
|
147
|
+
echo "[$(date)] 🎉 Hoàn tất. Nhìn bảng trên bằng mắt trước khi tin."
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { resolveBranding, tenantWithBranding } from "@goerp/core/branding"
|
|
2
|
-
|
|
3
1
|
import { Providers } from "@/providers"
|
|
4
2
|
import { getSystemCompany } from "@/server/services/company-service"
|
|
3
|
+
import { resolveBranding, tenantWithBranding } from "@goerp/core/branding"
|
|
5
4
|
|
|
6
5
|
import type { LocaleType } from "@goerp/core/types"
|
|
7
6
|
import type { ReactNode } from "react"
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getSystemCompany } from "@/server/services/company-service"
|
|
2
|
+
import {
|
|
3
|
+
PwaMetaTags,
|
|
4
|
+
buildBrandingMetadata,
|
|
5
|
+
resolveBranding,
|
|
6
|
+
} from "@goerp/core/branding"
|
|
2
7
|
|
|
3
8
|
import type { Metadata } from "next"
|
|
4
9
|
import type { ReactNode } from "react"
|
|
5
10
|
|
|
6
11
|
import { i18n } from "@/configs/i18n"
|
|
7
12
|
import { tenant } from "@/configs/tenant"
|
|
8
|
-
import { getSystemCompany } from "@/server/services/company-service"
|
|
9
13
|
|
|
10
14
|
import "./globals.css"
|
|
11
15
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
+
import { getSystemCompany } from "@/server/services/company-service"
|
|
1
2
|
import { buildAppManifest, resolveBranding } from "@goerp/core/branding"
|
|
2
3
|
|
|
3
4
|
import type { MetadataRoute } from "next"
|
|
4
5
|
|
|
5
6
|
import { i18n } from "@/configs/i18n"
|
|
6
7
|
import { tenant } from "@/configs/tenant"
|
|
7
|
-
import { getSystemCompany } from "@/server/services/company-service"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Web App Manifest — mức PWA "cài được lên màn hình chính", sinh ĐỘNG theo hồ
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FeaturePermissions,
|
|
3
|
+
LookupPolicy,
|
|
4
|
+
ResourceDeclaration,
|
|
5
|
+
} from "./types"
|
|
6
|
+
|
|
1
7
|
import masterDataPermissions from "./master-data.permissions"
|
|
2
8
|
import systemPermissions from "./system.permissions"
|
|
3
9
|
|
|
4
|
-
import type { FeaturePermissions, LookupPolicy, ResourceDeclaration } from "./types"
|
|
5
|
-
|
|
6
10
|
export type { FeaturePermissions, LookupPolicy, ResourceDeclaration }
|
|
7
11
|
|
|
8
12
|
/**
|
|
@@ -23,7 +27,7 @@ for (const feature of permissionRegistry) {
|
|
|
23
27
|
// Trùng mã resource là lỗi cấu hình im lặng nguy hiểm nhất: hai phân hệ
|
|
24
28
|
// tưởng mình sở hữu cùng một quyền. Ném ngay lúc nạp module.
|
|
25
29
|
throw new Error(
|
|
26
|
-
`Permission registry: resource "${resource.code}" khai trùng ở "${feature.feature}" và "${owner}"
|
|
30
|
+
`Permission registry: resource "${resource.code}" khai trùng ở "${feature.feature}" và "${owner}"`
|
|
27
31
|
)
|
|
28
32
|
}
|
|
29
33
|
resourceByCode.set(resource.code, resource)
|
|
@@ -31,7 +35,9 @@ for (const feature of permissionRegistry) {
|
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
export function getRegistryResource(
|
|
38
|
+
export function getRegistryResource(
|
|
39
|
+
code: string
|
|
40
|
+
): ResourceDeclaration | undefined {
|
|
35
41
|
return resourceByCode.get(code)
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -49,14 +49,17 @@ export async function register() {
|
|
|
49
49
|
* Hàm dưới chỉ còn cho ca đặc thù: job muốn gác theo MỘT múi giờ KHÁC múi giờ
|
|
50
50
|
* tiến trình (vd server chạy UTC nhưng nghiệp vụ theo giờ VN).
|
|
51
51
|
*/
|
|
52
|
-
export function isLocalHour(
|
|
52
|
+
export function isLocalHour(
|
|
53
|
+
hour: number,
|
|
54
|
+
timeZone = "Asia/Ho_Chi_Minh"
|
|
55
|
+
): boolean {
|
|
53
56
|
return (
|
|
54
57
|
Number(
|
|
55
58
|
new Intl.DateTimeFormat("en-GB", {
|
|
56
59
|
hour: "numeric",
|
|
57
60
|
hour12: false,
|
|
58
61
|
timeZone,
|
|
59
|
-
}).format(new Date())
|
|
62
|
+
}).format(new Date())
|
|
60
63
|
) === hour
|
|
61
64
|
)
|
|
62
65
|
}
|
|
@@ -70,7 +73,7 @@ export function isLocalHour(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolea
|
|
|
70
73
|
export async function onRequestError(
|
|
71
74
|
error: unknown,
|
|
72
75
|
request: { path?: string; method?: string; headers?: Record<string, string> },
|
|
73
|
-
context: { routePath?: string; routeType?: string; routerKind?: string }
|
|
76
|
+
context: { routePath?: string; routeType?: string; routerKind?: string }
|
|
74
77
|
) {
|
|
75
78
|
// logServerError kéo Prisma vào — không có trên edge runtime.
|
|
76
79
|
if (process.env.NEXT_RUNTIME !== "nodejs") return
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { customSessionClient } from "better-auth/client/plugins"
|
|
4
4
|
import { createAuthClient } from "better-auth/react"
|
|
5
5
|
|
|
6
|
-
import type { AuthBridgeClient } from "@goerp/core/ui"
|
|
7
6
|
import type { getAuth } from "@/lib/better-auth"
|
|
7
|
+
import type { AuthBridgeClient } from "@goerp/core/ui"
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Client Better Auth + cầu nối sang `AuthBridgeClient` của core.
|
|
@@ -22,7 +22,9 @@ export const authBridgeClient: AuthBridgeClient = {
|
|
|
22
22
|
useSession: () => {
|
|
23
23
|
const s = authClient.useSession()
|
|
24
24
|
return {
|
|
25
|
-
data: s.data
|
|
25
|
+
data: s.data
|
|
26
|
+
? { user: s.data.user, expires: s.data.session?.expiresAt }
|
|
27
|
+
: null,
|
|
26
28
|
status: s.isPending
|
|
27
29
|
? "loading"
|
|
28
30
|
: s.data
|
|
@@ -39,7 +41,8 @@ export const authBridgeClient: AuthBridgeClient = {
|
|
|
39
41
|
// Rate-limit của Better Auth trả 429 với message tiếng Anh — dịch cho người dùng.
|
|
40
42
|
if (error.status === 429) {
|
|
41
43
|
return {
|
|
42
|
-
error:
|
|
44
|
+
error:
|
|
45
|
+
"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
46
|
}
|
|
44
47
|
}
|
|
45
48
|
return { error: error.message ?? "Đăng nhập thất bại" }
|
|
@@ -21,11 +21,11 @@ export default defineConfig({
|
|
|
21
21
|
"@": path.resolve(__dirname, "./src"),
|
|
22
22
|
"@goerp/core/user/client-page": path.resolve(
|
|
23
23
|
__dirname,
|
|
24
|
-
"../../src/user/pages/users-client-page.tsx"
|
|
24
|
+
"../../src/user/pages/users-client-page.tsx"
|
|
25
25
|
),
|
|
26
26
|
"@goerp/core/tasks/ui": path.resolve(
|
|
27
27
|
__dirname,
|
|
28
|
-
"../../src/tasks/ui/task-list-client.tsx"
|
|
28
|
+
"../../src/tasks/ui/task-list-client.tsx"
|
|
29
29
|
),
|
|
30
30
|
"@goerp/core": path.resolve(__dirname, "../../src"),
|
|
31
31
|
},
|