@goplusvn/core 0.1.101 → 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 CHANGED
@@ -1,3 +1,46 @@
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
+
1
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
2
45
 
3
46
  0.1.100 cho Combobox/MultiSelect render inline (`portal=false`) để FocusScope của Dialog không cướp
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.101",
4
+ "version": "0.1.102",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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
- RUN apk add --no-cache libc6-compat openssl tzdata
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 gitignorePath = resolve(__dirname, ".gitignore")
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(gitignorePath) ? [includeIgnoreFile(gitignorePath)] : []),
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(path.join(__dirname, "node_modules/@goerp/core"))
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@^0.1.100",
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) throw new Error("DATABASE_URL chưa được set (xem .env.example)")
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("\nTiếp theo: pnpm rbac-sync (nạp resource/action/quyền từ registry)")
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()
@@ -1,6 +1,7 @@
1
1
  import path from "node:path"
2
2
 
3
3
  import "dotenv/config"
4
+
4
5
  import { defineConfig } from "prisma/config"
5
6
 
6
7
  /**
@@ -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 = process.env.SHADOW_DATABASE_URL ?? (() => {
38
- const u = new URL(dbUrl)
39
- u.pathname = `/${shadowDb}`
40
- return u.toString()
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, { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, ...env } })
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."
@@ -4,6 +4,7 @@ import type { ReactNode } from "react"
4
4
 
5
5
  import { dictionary } from "@/data/dictionary"
6
6
  import { navigations } from "@/data/navigations"
7
+
7
8
  import { getSession } from "@/lib/auth"
8
9
 
9
10
  import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
@@ -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,6 +1,6 @@
1
+ import { getSystemCompany } from "@/server/services/company-service"
1
2
  import { createBrandingLogoHandler } from "@goerp/core/branding"
2
3
 
3
- import { getSystemCompany } from "@/server/services/company-service"
4
4
  import { getFile } from "@/lib/storage"
5
5
 
6
6
  /**
@@ -1,11 +1,15 @@
1
- import { PwaMetaTags, buildBrandingMetadata, resolveBranding } from "@goerp/core/branding"
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(code: string): ResourceDeclaration | undefined {
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(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolean {
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 ? { user: s.data.user, expires: s.data.session?.expiresAt } : null,
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: "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.",
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
  },