# Hướng dẫn đưa CRM lên HawkHost (Semi-Dedicated, cPanel)

Địa chỉ đích: **https://crm.bizdev.io.vn/** · Tài khoản cPanel: `citnetco`

Tài liệu này gom toàn bộ các bước đã thực hiện thành công, kèm các lỗi đã gặp và cách xử lý. Các đường dẫn bên dưới là của tài khoản hiện tại, nếu đổi tài khoản/tên miền thì thay tương ứng.

| Mục | Giá trị |
|---|---|
| Thư mục ứng dụng trên server | `/home/citnetco/bizdev.io.vn/crm-app` |
| Môi trường Node của ứng dụng | `/home/citnetco/nodevenv/bizdev.io.vn/crm-app/22/` (Node 22) |
| File khởi động | `server.js` (nằm ở gốc dự án) |
| Cơ sở dữ liệu | PostgreSQL của cPanel, kết nối `localhost` |

---

## 0. Trước khi bắt đầu

- Ứng dụng phải chạy ở **gốc của một tên miền/subdomain** (ở đây là `crm.bizdev.io.vn`). **Không** chạy ở đường dẫn con kiểu `bizdev.io.vn/crm-app`, vì toàn bộ file giao diện sẽ báo 404 (xem mục Xử lý lỗi).
- Subdomain phải có **HTTPS**. Cookie đăng nhập chỉ được đặt khi chạy production qua HTTPS, nếu không sẽ đăng nhập xong lại bị đẩy về trang đăng nhập mà không báo lỗi.
- Chuẩn bị sẵn: mật khẩu database mạnh (**chỉ dùng chữ và số** để khỏi phải mã hoá ký tự đặc biệt), và một chuỗi `SESSION_SECRET` mới (cách sinh ở bước 7).

---

## 1. Tạo subdomain và bật HTTPS

1. cPanel → **Domains** (hoặc Subdomains) → tạo `crm.bizdev.io.vn`. Ô Document Root để mặc định, không cần đặt file vào đó.
2. cPanel → **SSL/TLS Status** → chọn `crm.bizdev.io.vn` → **Run AutoSSL**. Đợi 2–5 phút đến khi có ổ khoá.

---

## 2. Tạo PostgreSQL

1. cPanel → **PostgreSQL Database Wizard**.
2. Tạo database (ví dụ `citnetco_crm`), tạo user (ví dụ `citnetco_crmuser`) với mật khẩu mạnh, gán **toàn quyền** user đó trên database.
3. Ghi lại 3 giá trị: tên database, tên user, mật khẩu. Tên thường có tiền tố là tên tài khoản cPanel.

Kết nối từ app tới database là nội bộ (`localhost`), **không cần** mở kết nối từ xa hay tạo ticket.

---

## 3. Đóng gói code ở máy local

Chạy ở máy bạn:

```bash
cd /Users/nioempire/Documents/crm-saas
zip -r crm-deploy.zip . -x "node_modules/*" ".next/*" ".pgdata/*" ".git/*" "uploads/*" ".env.local"
```

- Loại `node_modules` (cài lại trên server), `.pgdata` (dữ liệu demo cục bộ) và `.next` (build sẽ làm lại).
- **Bắt buộc loại `.env.local`**: file này ở máy bạn đang trỏ tới database nhúng (`pglite`) và chứa khoá bí mật của máy local, không được mang lên server.

---

## 4. Upload và giải nén

```bash
# Ở máy local
scp crm-deploy.zip citnetco@<địa-chỉ-server>:~/

# SSH vào server, rồi:
mkdir -p ~/bizdev.io.vn/crm-app
cd ~/bizdev.io.vn/crm-app
unzip -o ~/crm-deploy.zip
```

Thông tin SSH (địa chỉ máy chủ, tên đăng nhập, mật khẩu) nằm trong email "New Account Information" của HawkHost. Không đặt thư mục này trong `public_html`.

---

## 5. Tạo ứng dụng Node.js trong cPanel

**Trước khi tạo, kiểm tra thư mục ứng dụng KHÔNG có sẵn `node_modules` thật:**

```bash
ls -la ~/bizdev.io.vn/crm-app/node_modules 2>&1
```

Nếu có (ví dụ do lỡ chạy `npm install` tay trước khi tạo app), xoá đi trước: `rm -rf ~/bizdev.io.vn/crm-app/node_modules`. Lý do: cPanel (CloudLinux NodeJS Selector) bắt buộc tự quản lý `node_modules` bằng một **liên kết tượng trưng (symlink)** trỏ tới môi trường ảo riêng — có sẵn thư mục thật ở đó sẽ làm bước Save/Create báo lỗi *"Cloudlinux NodeJS Selector demands to store node modules for application in separate folder..."* (xem thêm mục Xử lý lỗi).

cPanel → **Software → Setup Node.js App** → **Create Application**:

| Ô | Giá trị |
|---|---|
| Node.js version | **22.x** (tối thiểu 20) |
| Application mode | Production |
| Application root | `bizdev.io.vn/crm-app` |
| Application URL | chọn domain **`crm.bizdev.io.vn`**, để trống phần đường dẫn |
| Application startup file | `server.js` |

Bấm **Create**. Sau khi tạo, đầu trang hiện dòng **"Enter to the virtual environment"** bắt đầu bằng `source ...`. Dòng đó dùng ở bước tiếp theo.

**Nếu sau này cần đổi Application URL của một app đã tồn tại** (ví dụ tạo nhầm URL lúc đầu): bấm **Restart**/**Start** ứng dụng ở địa chỉ hiện tại trước, đợi vài giây, rồi mới sửa URL và Save — cPanel kiểm tra ứng dụng có phản hồi ở địa chỉ cũ trước khi cho đổi, sửa khi app đang dừng sẽ báo lỗi *"Web application is inaccessible by its address..."*. Nếu vẫn lỗi, cách chắc ăn hơn là **Delete** app đó (chỉ xoá cấu hình + liên kết `node_modules`, **không xoá** thư mục code hay `.env.local`) rồi **Create Application** lại từ đầu với URL đúng ngay từ lúc tạo.

---

## 6. Kích hoạt môi trường Node trong SSH (làm mỗi lần mở SSH mới)

**Đây là bước dễ quên nhất.** Shell SSH mặc định dùng Node đời cũ của hệ thống, không chạy được các công cụ của dự án. Phải kích hoạt môi trường Node 22 trước khi gõ bất kỳ lệnh `npm` nào:

```bash
source /home/citnetco/nodevenv/bizdev.io.vn/crm-app/22/bin/activate && cd /home/citnetco/bizdev.io.vn/crm-app
node -v     # phải ra v22.x.x
```

(Copy đúng dòng cPanel hiển thị ở bước 5 nếu đường dẫn khác.) Thành công thì đầu dòng lệnh hiện thêm tên môi trường dạng `[bizdev.io.vn/crm-app (22)]`.

Sau đó cài thư viện, **kèm nhóm dev** (cần cho `tsx`, `typescript`, `tailwindcss` khi migrate và build):

```bash
npm install --include=dev
```

Nếu `tsx` vẫn không có (`ls node_modules/.bin/tsx`), chạy `NODE_ENV=development npm install`.

---

## 7. Tạo file `.env.local` trên server

Tạo bằng tay ngay trên server:

```bash
cd ~/bizdev.io.vn/crm-app
nano .env.local
```

Dán nội dung sau (thay các chỗ viết hoa), lưu bằng `Ctrl+O`, `Enter`, thoát `Ctrl+X`:

```env
DB_DRIVER=pg
DATABASE_URL=postgresql://TEN_USER_DB:MAT_KHAU_DB@localhost:5432/TEN_DATABASE
SESSION_SECRET=<chuỗi 64 ký tự, sinh bên dưới>
NODE_ENV=production
APP_TIMEZONE=Asia/Ho_Chi_Minh
CHECKIN_RADIUS_METERS=300
UPLOAD_DIR=/home/citnetco/crm-uploads
```

**Sinh `SESSION_SECRET`** bằng lệnh sau (trên server hoặc máy bạn), copy nguyên dòng in ra:

```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```

Mỗi lần chạy ra một chuỗi khác nhau, dùng chuỗi nào cũng được. Giữ bí mật; đổi chuỗi này về sau thì mọi người bị đăng xuất, nhưng không mất dữ liệu.

Tạo thư mục lưu ảnh/tệp tải lên (nằm ngoài thư mục code để khỏi mất khi upload lại code):

```bash
mkdir -p /home/citnetco/crm-uploads
```

Ghi chú:
- Nếu mật khẩu database có ký tự đặc biệt, phải mã hoá trong `DATABASE_URL` (`@`→`%40`, `/`→`%2F`, `#`→`%23`, `:`→`%3A`). Dùng mật khẩu chỉ gồm chữ và số là đơn giản nhất.
- **Không thêm `DB_DRIVER`/`DATABASE_URL`/`SESSION_SECRET` vào ô Environment Variables của cPanel.** Ban đầu tài liệu này khuyên thêm cả hai nơi "để dự phòng" — thực tế việc đó gây lỗi: Next.js ưu tiên biến đã có sẵn ở cấp hệ thống (Passenger đặt từ ô Environment Variables) **cao hơn** nội dung trong `.env.local`. Nếu hai nơi ghi khác nhau (kể cả chỉ một ký tự), trang web thật sẽ âm thầm dùng giá trị sai — trong khi lệnh SSH (`db:push`, `db:seed`) chỉ đọc `.env.local` nên vẫn chạy đúng, gây ra tình trạng "lệnh chạy được nhưng web báo lỗi thiếu bảng/dữ liệu" rất khó đoán. Chỉ dùng **duy nhất** file `.env.local` làm nguồn cấu hình.
- Muốn "Quên mật khẩu" gửi được email, thêm `SMTP_URL=smtp://tendangnhap:matkhau@smtp.congty.vn:587` và `SMTP_FROM`. Không có thì hệ thống chỉ ghi đường dẫn đặt lại ra log.

---

## 8. Tạo bảng và nạp dữ liệu

**Nếu PostgreSQL của hosting cũ hơn bản 13** (kiểm tra bằng `psql -U ... -d ... -h localhost -c "SELECT version();"`), `db:push` sẽ báo lỗi `function gen_random_uuid() does not exist`, vì hàm này chỉ có sẵn từ bản 13 trở lên, bản cũ hơn cần extension `pgcrypto`. Nhiều hosting chia sẻ chưa cài `pgcrypto` ở tầng hệ điều hành (`CREATE EXTENSION pgcrypto;` báo `could not open extension control file...`) — việc đó chỉ nhà cung cấp hosting cài được, không tự làm qua cPanel được.

**Cách né, không cần đợi hosting, không đổi code dự án:** tạo một hàm cùng tên bằng SQL thuần (không cần extension) ngay trong database, trước khi chạy `db:push`:

```bash
psql -U TEN_USER_DB -d TEN_DATABASE -h localhost
```

```sql
CREATE OR REPLACE FUNCTION public.gen_random_uuid() RETURNS uuid AS $$
  SELECT md5(random()::text || clock_timestamp()::text)::uuid;
$$ LANGUAGE sql;
```

Thoát bằng `\q`. Đây không phải UUID ngẫu nhiên theo chuẩn mật mã học như bản gốc, chỉ là chuỗi trông giống UUID đủ dùng làm mã định danh duy nhất — không ảnh hưởng gì tới ứng dụng. Nếu sau này hosting cài `pgcrypto` thật, xoá hàm tạm này đi (`DROP FUNCTION public.gen_random_uuid();`) là dùng lại bản gốc.

(Nếu dùng phpPgAdmin thay vì `psql`, chú ý ô "Query"/tìm kiếm mặc định tự bọc câu lệnh trong `SELECT COUNT(*) FROM (...)`, sẽ báo lỗi cú pháp với `CREATE ...` — cần dùng đúng mục **SQL/Execute SQL** riêng, hoặc dùng `psql` qua SSH cho chắc.)

Sau đó chạy như bình thường:

```bash
npm run db:push     # tạo cấu trúc bảng; chạy lại nhiều lần vẫn an toàn
npm run db:seed     # nạp dữ liệu demo (Mediworld)
```

> **Cẩn thận:** `db:seed` **xoá sạch rồi nạp lại** toàn bộ dữ liệu. Chỉ chạy **một lần** khi khởi tạo. Sau khi khách hoặc bạn đã nhập dữ liệu thật thì **không chạy lại**.

`db:push` thành công khi in danh sách các file migration có dấu ✓ và kết thúc bằng "Xong. Cơ sở dữ liệu đã sẵn sàng."

---

## 9. Build

Thử theo thứ tự:

**Cách A: build ngay trên server** (hosting giới hạn số tiến trình nên có thể lỗi, xem mục Xử lý lỗi):

```bash
RAYON_NUM_THREADS=1 NEXT_TELEMETRY_DISABLED=1 npm run build -- --no-lint
```

Nếu vẫn báo `EAGAIN`, mở `next.config.ts` trên server và thêm `cpus: 1,` vào khối `experimental`:

```ts
  experimental: {
    cpus: 1,
    serverActions: { bodySizeLimit: "10mb" },
  },
```

Rồi build lại. Build thành công khi cuối log hiện danh sách các trang kèm dung lượng.

**Cách B: build ở máy local rồi upload kết quả** (chắc chắn nhất):

```bash
# Ở máy local
cd /Users/nioempire/Documents/crm-saas
npm run build
zip -r next-build.zip .next
scp next-build.zip citnetco@<địa-chỉ-server>:~/

# Trên server (đã kích hoạt môi trường Node)
cd ~/bizdev.io.vn/crm-app
rm -rf .next
unzip ~/next-build.zip
```

---

## 10. Khởi động và kiểm tra

1. cPanel → Setup Node.js App → ứng dụng → **Restart**.
2. Mở **https://crm.bizdev.io.vn/login**. Thấy trang đăng nhập là thành công.
3. Đăng nhập bằng `giamdoc@minhanh.vn` / `matkhau123` (tài khoản Ban giám đốc trong dữ liệu demo).
4. Kiểm tra nhanh: trang Tổng quan hiện số liệu (doanh thu tháng, biểu đồ), mở Đơn hàng và Khách hàng thấy danh sách.

**Nếu bước nào ở đây báo lỗi** (500, trang trắng, hoặc trình duyệt chỉ hiện thông báo chung chung "An error occurred in the Server Components render..."): trình duyệt production **cố tình giấu nội dung lỗi thật**, phải xem log trên server mới biết đúng nguyên nhân:

```bash
tail -80 /home/citnetco/bizdev.io.vn/crm-app/stderr.log
```

File này cPanel tự tạo sẵn cho mọi ứng dụng Node, ghi lại mọi lỗi/exception thật của ứng dụng — không cần cấu hình gì thêm. (Còn biến `LSNODE_CONSOLE_LOG` nếu có cấu hình chỉ bắt `console.log()` thường, không bắt lỗi.) Thử lại thao tác bị lỗi trên trình duyệt trước, rồi mới xem log để thấy đúng dòng mới nhất.

---

## 11. Việc bắt buộc sau khi chạy được, trước khi gửi link cho khách

- [ ] **Đổi mật khẩu** các tài khoản demo, ít nhất tài khoản Ban giám đốc. Mật khẩu mẫu `matkhau123` rất dễ đoán và giờ đã nằm trên internet. Đổi trong Danh mục → Người dùng & phân quyền, hoặc Tài khoản của tôi.
- [ ] Xác nhận thanh địa chỉ có **ổ khoá HTTPS**.
- [ ] (Nếu muốn dùng "Quên mật khẩu") điền `SMTP_URL` như bước 7.
- [ ] Thử một luồng thật: tạo đơn hàng bằng tài khoản NVKD → duyệt bằng Giám đốc Khu vực → xem doanh thu cập nhật.

### Job chạy đêm (tuỳ chọn, cho phân hạng/cảnh báo/dự báo tự cập nhật)

cPanel → **Cron Jobs** → thêm dòng chạy lúc 1 giờ sáng:

```
0 1 * * * source /home/citnetco/nodevenv/bizdev.io.vn/crm-app/22/bin/activate && cd /home/citnetco/bizdev.io.vn/crm-app && npm run jobs:nightly >> /home/citnetco/crm-nightly.log 2>&1
```

Chạy thử tay một lần trước (`npm run jobs:nightly`); thành công sẽ in số đại lý phân hạng, số cảnh báo và "Hoàn tất sau ...s".

---

## 12. Cập nhật phiên bản về sau

Chạy lần lượt từ trên xuống, chép nguyên từng khối lệnh. Thay `<địa-chỉ-server>` bằng địa chỉ trong email "New Account Information" của hosting.

> **Không** chạy `npm run db:seed` khi cập nhật — lệnh đó xoá sạch dữ liệu.

### A. Ở máy local

**A1. Dừng `npm run dev` nếu đang chạy** (Ctrl+C ở cửa sổ đó). Build trong lúc dev đang chạy làm hỏng thư mục `.next` (xem `CLAUDE.md`).

**A2. Kiểm tra mã trước khi đóng gói** — lệnh nào báo lỗi thì dừng lại, không đóng gói:

```bash
cd /Users/nioempire/Documents/crm-saas
npm run lint && npm run typecheck && npm test
```

**A3. Đóng gói mã nguồn**, kèm một tệp `PHIEN-BAN.txt` ghi mã commit để sau này tra được server đang chạy bản nào:

```bash
cd /Users/nioempire/Documents/crm-saas
rm -f crm-deploy.zip next-build.zip
zip -r crm-deploy.zip . -x "node_modules/*" ".next/*" ".pgdata/*" ".git/*" "uploads/*" ".env.local" "trao-doi-khach/*" "*.zip"
git rev-parse --short HEAD > /tmp/PHIEN-BAN.txt && zip -j crm-deploy.zip /tmp/PHIEN-BAN.txt
```

Loại `trao-doi-khach/` là bắt buộc: thư mục này chứa báo giá, email nháp, tài liệu trao đổi riêng với khách, không được lên server.

**A4. Build ở local và đóng gói kết quả build:**

```bash
cd /Users/nioempire/Documents/crm-saas
rm -rf .next && npm run build
zip -r next-build.zip .next
```

Build thành công khi cuối log hiện danh sách các trang kèm dung lượng.

**A5. Đưa hai tệp lên server:**

```bash
scp /Users/nioempire/Documents/crm-saas/crm-deploy.zip /Users/nioempire/Documents/crm-saas/next-build.zip citnetco@<địa-chỉ-server>:~/
```

### B. Trên server (SSH)

**B1. Vào server và kích hoạt môi trường Node 22** — bắt buộc mỗi lần mở SSH mới, nếu không mọi lệnh `npm` chạy bằng Node đời cũ và lỗi:

```bash
ssh citnetco@<địa-chỉ-server>
```

```bash
source /home/citnetco/nodevenv/bizdev.io.vn/crm-app/22/bin/activate && cd /home/citnetco/bizdev.io.vn/crm-app
node -v
```

`node -v` phải ra `v22.x.x`, đầu dòng lệnh hiện `[bizdev.io.vn/crm-app (22)]`.

**B2. Ghi lại bản đang chạy** — mã commit và migration mới nhất trên server, để lát nữa biết bản mới có thay đổi cấu trúc dữ liệu hay không (lần đầu dùng quy trình này, dòng `PHIEN-BAN.txt` báo không có tệp — bình thường):

```bash
cat /home/citnetco/bizdev.io.vn/crm-app/PHIEN-BAN.txt
ls /home/citnetco/bizdev.io.vn/crm-app/drizzle/*.sql | tail -1
```

**B3. Sao lưu cơ sở dữ liệu và mã đang chạy** (để quay lại được nếu bản mới lỗi):

```bash
mkdir -p ~/sao-luu
pg_dump -Fc "$(grep '^DATABASE_URL=' /home/citnetco/bizdev.io.vn/crm-app/.env.local | cut -d= -f2-)" -f ~/sao-luu/db-truoc-cap-nhat-$(date +%F-%H%M).dump
tar czf ~/sao-luu/code-truoc-cap-nhat-$(date +%F-%H%M).tar.gz --exclude=node_modules -C /home/citnetco/bizdev.io.vn crm-app
ls -lh ~/sao-luu | tail -3
```

Lệnh `pg_dump` đọc thẳng thông tin kết nối trong `.env.local`, không cần gõ tên/mật khẩu database. Tệp `.dump` phải có dung lượng lớn hơn 0.

**B4. Giải nén mã mới và bản build mới:**

```bash
cd /home/citnetco/bizdev.io.vn/crm-app
unzip -o ~/crm-deploy.zip
rm -rf .next
unzip -q ~/next-build.zip
cat PHIEN-BAN.txt
ls drizzle/*.sql | tail -1
```

`cat PHIEN-BAN.txt` phải ra đúng mã commit vừa đóng gói ở bước A3. So dòng `ls drizzle/...` với dòng tương ứng ở bước B2: **khác nhau nghĩa là bản mới có migration** (thay đổi cấu trúc dữ liệu) — ghi nhớ điều này, cần đến nếu phải quay lại bản cũ (mục C). Ảnh và tệp tải lên nằm ở `/home/citnetco/crm-uploads`, ngoài thư mục ứng dụng, nên không bị ảnh hưởng. `.env.local` trên server cũng giữ nguyên (gói không chứa tệp này).

**B5. Cài thư viện và áp thay đổi cấu trúc dữ liệu:**

```bash
npm install --include=dev
npm run db:push
```

Chạy cả hai lệnh ở **mọi** lần cập nhật, không cần đoán bản này có đổi thư viện hay có migration mới hay không. `npm install` không đổi gì nếu thư viện như cũ. `db:push` chạy lại toàn bộ migration nhưng tự bỏ qua phần đã có, nên an toàn khi chạy nhiều lần. Nó in dấu ✓ cho **mọi** tệp, cả tệp cũ, nên đừng dựa vào danh sách này để biết có migration mới hay không — dùng phép so ở bước B4. Thành công khi kết thúc bằng "Xong. Cơ sở dữ liệu đã sẵn sàng."

**B6. Khởi động lại ứng dụng:** cPanel → **Software → Setup Node.js App** → dòng `crm.bizdev.io.vn` → bấm **Restart**.

**B7. Kiểm tra:**

```bash
curl -s https://crm.bizdev.io.vn/api/suc-khoe
```

Phải ra `{"ok":true,...}`. Sau đó mở `https://crm.bizdev.io.vn/login` trên trình duyệt, đăng nhập Ban giám đốc, xem trang Tổng quan, Đơn hàng và Khách hàng có số liệu.

Nếu báo lỗi 500 hoặc trang trắng, xem lỗi thật trong log:

```bash
tail -80 /home/citnetco/bizdev.io.vn/crm-app/stderr.log
```

**B8. Dọn tệp tải lên** (sau khi đã kiểm tra chạy tốt):

```bash
rm ~/crm-deploy.zip ~/next-build.zip
```

### C. Nếu bản mới lỗi — quay lại bản cũ

Dùng đúng hai tệp sao lưu ở bước B3. Xem tên tệp bằng `ls ~/sao-luu`, rồi thay `NGAY-GIO` bên dưới bằng phần ngày giờ trong tên tệp:

```bash
cd /home/citnetco/bizdev.io.vn
rm -rf crm-app/.next
tar xzf ~/sao-luu/code-truoc-cap-nhat-NGAY-GIO.tar.gz
cat crm-app/PHIEN-BAN.txt
```

(Xoá `.next` trước để bản build mới không trộn lẫn với bản build cũ vừa giải nén ra. `cat` phải ra mã commit cũ đã ghi ở bước B2.)

Nếu bản mới **có migration** (dòng `ls drizzle/...` ở bước B4 khác bước B2), khôi phục cả cơ sở dữ liệu:

```bash
pg_restore --clean --if-exists -d "$(grep '^DATABASE_URL=' /home/citnetco/bizdev.io.vn/crm-app/.env.local | cut -d= -f2-)" ~/sao-luu/db-truoc-cap-nhat-NGAY-GIO.dump
```

Sau đó Restart ứng dụng trong cPanel như bước B6 và kiểm tra lại như B7. Lưu ý: khôi phục cơ sở dữ liệu làm mất mọi dữ liệu nhập vào **sau** lúc sao lưu.

### Cập nhật dữ liệu mẫu 04/10/2026 (không có migration, không cần build)

Chỉ đổi `src/db/seed.ts`: phường/xã của khách mẫu nay khớp đúng tỉnh (bản cũ ghép ngẫu nhiên, ra những địa chỉ như "Xã Bến Nghé, Hà Nội"). Không ảnh hưởng ứng dụng đang chạy — có tác dụng ở lần seed lại tiếp theo. Cách upload và seed lại: mục 14.

### Cập nhật lịch sử chỉnh sửa, khoá thông tin đã duyệt, vá bảo mật (có migration 0025)

Bản này thêm một chỉ mục cho bảng nhật ký (migration `0025`, chỉ `CREATE INDEX`, an toàn) và **cập nhật `package-lock.json`** (vá `next`, `nodemailer`, `sharp`). Các bước: upload → `npm install --include=dev` → **bắt buộc** `npm run db:push` (áp cả 0019–0025 nếu host còn cũ) → build → Restart. Không chạy `db:seed`. Lần đầu tạo chỉ mục trên bảng nhật ký lớn có thể mất vài giây.

- Ban giám đốc có **Nhật ký thao tác** mới (tìm kiếm, lọc theo người/hành động/đối tượng/ngày, phân trang, xuất Excel) và khung **Lịch sử chỉnh sửa** ở đơn hàng, hồ sơ khách, hồ sơ nhân viên, phiếu đề xuất. Nhật ký chỉ ghi từ bản này trở đi; các thao tác cũ chưa được ghi thì không có.
- Ban giám đốc sửa/xoá được **phiếu đề xuất** đã gửi (trước đây không ai làm được); người khác vẫn không sửa/xoá được. Đơn hàng đã duyệt vẫn chỉ Ban giám đốc sửa/huỷ/xoá được, nay ghi rõ dòng hàng "trước → sau".
- Sửa lỗi: nút **Ngừng phòng ban** báo lỗi với phòng ban có phòng con; quản lý xoá được dòng KPI tự tạo của nhân viên ngoài nhánh; nhiều thông báo lỗi trước đây bị ẩn ở bản chạy thật nay hiện rõ bằng tiếng Việt.
- Thêm điểm kiểm tra sức khoẻ `/api/suc-khoe` cho dịch vụ giám sát, biến `PG_POOL_MAX` (giới hạn kết nối PostgreSQL) và header bảo mật; xem `TAI-LIEU-BAN-GIAO-VA-BAO-TRI.md` (sao lưu, giám sát, xử lý sự cố).

### Cập nhật cảnh báo và dự báo dự trù (không có migration)

Chỉ upload bản build mới rồi Restart, **không** cần `db:push` (nếu host còn cũ hơn thì vẫn áp 0019–0024 như các mục dưới), **không** chạy `db:seed`.

- Bộ cảnh báo được viết lại. Bản cũ **không bao giờ sinh cảnh báo "đại lý giảm doanh thu"** (so sánh nhầm với bình quân 12 tháng) — nay so trực tiếp theo tháng / quý / năm nên có cảnh báo ngay từ đêm đầu. Sau khi cập nhật, chạy tay một lần `npm run jobs:nightly` (hoặc chờ job đêm) rồi vào **Cảnh báo** để thấy danh sách mới.
- Cảnh báo cũ còn "Chưa xử lý" sẽ **tự chuyển "Hết hiệu lực"** ở lần quét đầu tiên (kỳ mới đã đánh giá lại); cảnh báo nào còn đúng sẽ được sinh lại trong kỳ mới. Người dùng bấm **"Đánh dấu đã xử lý"** thì cảnh báo được giữ làm lịch sử.
- Mục **Dự báo dự trù** nay nằm ở trang **Cảnh báo** (không còn ở Phân tích → Sản phẩm), tính cho **3 tháng tới**, chỉnh tay được (ô "Điều chỉnh"), số chỉnh tay **không mất** khi hệ thống tính lại mỗi đêm. Dự báo dựa trên 3 tháng đã đóng sổ gần nhất, nên cần job đêm chạy đều (mục "Job chạy đêm" bên dưới).
- Ngưỡng % sụt giảm (mặc định 30%) và ngưỡng % hoàn thành chỉ tiêu nhân sự (mặc định 70%) chỉnh ở **Danh mục → Cài đặt**.

### Cập nhật báo cáo chuyển giao (không có migration)

Chỉ upload bản build mới rồi Restart, **không** cần `db:push`, **không** chạy `db:seed`.

- Mục mới **Báo cáo → Chuyển giao** (nút "Chuyển giao" ở trang Báo cáo): số lớp theo loại lớp theo ngày / tuần / tháng / quý / năm, theo toàn công ty, khu vực, phòng ban / team, nhân viên; kèm xuất Excel.
- Trang **Danh mục → Chuyên đề & lớp học** nay chỉ hiện và cho sửa lớp trong phạm vi của người dùng (trước đây quản lý thấy và sửa được lớp của mọi vùng). Nhân viên chuyên môn vào **Công việc → Ghi lớp chuyển giao** để tự ghi lớp của mình.
- Loại lớp nhập ở ô "Loại" (7 loại gợi ý: Lớp sản phẩm, Lớp quy trình, Chuyển giao tại cơ sở khách, Báo cáo Hội nghị/Bệnh viện, Chuyên đề, Lớp học, Hội thảo). Lớp cũ giữ nguyên loại đã lưu; báo cáo gom theo đúng loại có trong dữ liệu.

### Cập nhật báo cáo khách hàng và sửa múi giờ (không có migration)

Chỉ upload bản build mới rồi Restart, **không** cần `db:push`, **không** chạy `db:seed`.

- Số liệu "khách mở mới" và "lượt viếng thăm" nay tính theo **giờ Việt Nam**. Bản cũ cắt ngày theo múi giờ của cơ sở dữ liệu (UTC), nên khách tạo hoặc check-in trước 7 giờ sáng bị tính vào hôm trước — sau khi cập nhật, một vài số của các kỳ cũ có thể nhích 1–2 đơn vị so với trước. Đó là số đúng, không phải lỗi mới.
- Mục mới **Phân tích → Khách hàng** (xếp hạng khách, tỷ lệ khách mở mới, danh sách khách mới) và ô lọc **Phòng ban / Team** ở danh sách Khách hàng. Khách đã ngưng hoạt động không còn được tính vào các báo cáo.

### Cập nhật báo cáo bán hàng / phân tích (không có migration)

Chỉ upload bản build mới rồi Restart, **không** cần `db:push` (nếu host còn cũ hơn thì vẫn áp 0019–0024 như các mục dưới), **không** chạy `db:seed`.

- Trang **Phân tích** chia 6 mục (Tổng quan, Kế hoạch, Tỷ trọng, Sản phẩm, Khuyến mãi, Định biên) và chỉ dành cho Ban giám đốc / Giám đốc khu vực / Quản lý. Bản này **vá lỗ hổng lộ KPI của cả công ty** cho mọi vai trò ở trang Phân tích và file Excel phân tích — nên cập nhật sớm nếu host đang chạy bản cũ.
- Để bảng "Thực hiện so với kế hoạch" theo **khu vực** và **phòng ban** có số để so, vào Danh mục → Giao chỉ tiêu và giao chỉ tiêu cho từng khu vực (miền/tỉnh) và từng phòng ban/team (ngoài chỉ tiêu nhân viên). Chưa giao thì bảng hiện "—" ở cột kế hoạch (vẫn có thực hiện và tỷ trọng); kế hoạch của cấp cha tự cộng từ cấp con nếu cấp con đã có.
- "Định biên NVKD" = số nhân viên có vai trò Nhân viên kinh doanh đang hoạt động (không tính nhân viên chuyên môn và quản lý): kiểm tra vai trò/chức vụ của từng người ở Danh mục → Người dùng để định biên đúng.

### Cập nhật có migration 0024 (Văn bản công ty: phân loại, trích yếu, thêm Word/Excel/PowerPoint)

Bản này thêm 2 cột cho bảng `company_documents` (`category`, `description`) — migration `0024`. **Bắt buộc** chạy `npm run db:push` (áp cả 0019–0024 nếu bản trên host còn cũ); không chạy `db:seed`. Văn bản đã đăng từ trước tự vào loại "Văn bản", vẫn mở/tải bình thường. Thư mục `crm-uploads` không đổi cấu trúc (tệp văn bản nằm trong `van-ban/`) — vẫn không được ghi đè khi cập nhật và cần sao lưu cùng cơ sở dữ liệu. Từ bản này, xoá một văn bản cũng xoá tệp trên đĩa (trước đây tệp ở lại mãi).

### Cập nhật nhân viên chuyên môn (không có migration)

Bản này không đổi cấu trúc dữ liệu — chỉ upload bản build mới rồi Restart, **không** cần `db:push` (nếu host còn cũ hơn thì vẫn áp 0019–0023 như các mục dưới), **không** chạy `db:seed`.

- Tab **Chuyên môn** ở Danh mục → Nhân viên (và nút "Nhân viên chuyên môn" ở trang Danh mục) nhận diện nhân viên chuyên môn theo: **chức vụ có chữ "chuyên môn"** hoặc **thuộc phòng ban có tên chứa "chuyên môn"**. Trước đây ai từng mở một chuyên đề cũng bị tính là chuyên môn — nay không còn.
- Để "quản lý chuyên môn khu vực chỉ thấy nhân viên khu vực mình": Danh mục → Phòng ban tạo "Phòng chuyên môn" và các team con theo miền (tên nên chứa chữ "chuyên môn"), đặt nhân viên vào đúng team, đặt vai trò **Quản lý** cho quản lý chuyên môn khu vực và gán vào team của họ (phạm vi xem theo cây phòng ban, giống các bộ phận khác).
- Nhân viên chuyên môn không còn bị đưa vào KPI doanh thu; cảnh báo "không đạt chỉ tiêu 2 tháng liên tiếp" chỉ phát sinh với người có chỉ tiêu doanh thu. Nếu trước đó đã có cảnh báo sai cho người chưa được giao chỉ tiêu, nó tự hết khi hệ thống quét sang tháng mới.

### Cập nhật có migration 0023 (phiếu đề xuất: duyệt lần lượt)

Bản này thêm cột `details`, `decided_at` cho `proposals`, cột `step_label` cho `proposal_approvers` và ràng buộc duy nhất (phiếu, người duyệt) — migration `0023`. **Bắt buộc** chạy `npm run db:push` (áp cả 0019–0023 nếu bản trên host còn cũ); không chạy `db:seed`. Phiếu đề xuất cũ vẫn hiển thị bình thường (chưa có chi tiết theo loại). Muốn Trưởng bộ phận được tự tìm cho chuỗi duyệt thì phòng ban phải được đặt trưởng phòng ở Danh mục → Phòng ban.

### Cập nhật có migration 0022 (công việc: thời điểm cập nhật)

Bản này thêm cột `updated_at` vào bảng `tasks` (migration `0022`, việc cũ lấy giờ lúc chạy migration). **Bắt buộc** chạy `npm run db:push` khi cập nhật (áp cả 0019–0022 nếu bản trên host còn cũ); không chạy `db:seed`. Bản này cũng thêm trang **Công việc → Lịch** và mục "Lịch 7 ngày tới" ở Trang chủ, không cần cấu hình gì thêm.

### Cập nhật có migration 0021 (KPI sự kiện: chỉ tiêu phòng ban, hạn chót mời khách)

Bản này thêm bảng `invite_event_dept_quotas` và cột `invite_deadline` cho `invite_events` (migration `0021`). **Bắt buộc** chạy `npm run db:push` khi cập nhật (áp cả 0019–0021 nếu bản trên host còn cũ); không chạy `db:seed`.

### Cập nhật có migration 0020 (ủy quyền đăng thông báo)

Bản này thêm cột `can_post_announcements` vào bảng `users` (migration `0020`, mặc định tắt). **Bắt buộc** chạy `npm run db:push` khi cập nhật; không chạy `db:seed`. Nếu bạn cập nhật từ bản cũ hơn 0019 thì lần `db:push` này áp cả 0019 và 0020.

### Cập nhật có migration 0019 (phân quyền cha–con, quản lý tài khoản)

Bản này thêm cột `password_changed_at` vào bảng `users` (migration `0019`). **Bắt buộc** chạy `npm run db:push` trong lần cập nhật này (không chạy `db:seed`). Sau khi lên bản mới, mọi người đang đăng nhập vẫn giữ phiên bình thường (cột mới để trống nghĩa là chưa từng đổi mật khẩu).

### Lần cập nhật 25/09/2026 (bổ sung theo góp ý của khách)

Bản này thêm 3 file migration (`0016`, `0017`, `0018`): cột hồ sơ mới cho nhân viên/khách hàng/sản phẩm/khoá chuyển giao/thông báo/đơn hàng, bảng `tasks` (danh sách công việc), `order_attachments` (tệp đính kèm đơn) và `custom_kpi_logs` (ghi nhận KPI hằng ngày). Chạy `npm run db:push` là đủ — các cột mới đều có giá trị mặc định nên dữ liệu đang có không bị ảnh hưởng, và tương thích PostgreSQL 10 (không dùng `ALTER TYPE ... ADD VALUE`).

Dữ liệu demo cũ sẽ **thiếu** các trường mới (ngày sinh nhân viên, nhu cầu/nguồn khách tiềm năng, thông tin khoá chuyển giao, việc cần làm…), nên nhìn hơi trống khi khách xem. Trong giai đoạn demo, khi chưa có dữ liệu thật, có thể chạy lại `npm run db:seed` **một lần** để nạp bộ dữ liệu mẫu mới đầy đủ hơn (lệnh này xoá sạch dữ liệu cũ, kể cả tài khoản đã đổi mật khẩu — đổi lại mật khẩu sau khi nạp). Sau khi khách bắt đầu nhập dữ liệu thật thì tuyệt đối không chạy lại.

Hai thư mục con mới trong nơi lưu tệp (`thong-bao`, `don-hang`) tự được tạo khi có người tải tệp đầu tiên — không cần tạo tay.

---

## 13. Lệnh hữu ích trên server

Luôn kích hoạt môi trường Node (bước 6) trước.

| Việc | Lệnh |
|---|---|
| Mở khoá đăng nhập cho tài khoản bị khoá do nhập sai 5 lần (khi không ai còn vào được giao diện) | `npm run user:unlock -- email@congty.vn` |
| Chạy job chạy đêm bằng tay | `npm run jobs:nightly` |
| Kiểm tra Node đang dùng | `node -v` |

Người bị tạm khoá đăng nhập (sai mật khẩu 5 lần → khoá 15 phút) thường được Ban giám đốc mở khoá ngay trên giao diện: Danh mục → Người dùng → nút **"Cho đăng nhập lại"**.

---

## 14. Làm mới dữ liệu demo trước buổi demo (seed lại + đổi mật khẩu)

**Vì sao cần:** dữ liệu mẫu được sinh theo **ngày chạy seed**. Sang tháng mới mà không seed lại, trang Tổng quan sẽ hiện doanh thu 0, 0 đơn, "Chưa giao chỉ tiêu", đại lý lấy hàng 0/41 và hàng loạt cảnh báo "giảm 100%" — trông như hệ thống hỏng, dù không có lỗi nào. Nên seed lại **vào đúng ngày demo, hoặc ít nhất trong cùng tháng với buổi demo**.

> **Chỉ làm khi host vẫn là bản demo.** `db:seed` **xoá sạch rồi nạp lại** toàn bộ dữ liệu: khách, đơn, người dùng, mật khẩu đã đổi, nhật ký. Nếu khách đã bắt đầu nhập dữ liệu thật thì **không làm**.

### Bước 1. Đưa file seed mới lên (chỉ cần khi `seed.ts` có thay đổi, ví dụ bản 04/10/2026)

Ở máy local:

```bash
scp /Users/nioempire/Documents/crm-saas/src/db/seed.ts citnetco@<địa-chỉ-server>:~/bizdev.io.vn/crm-app/src/db/seed.ts
```

Hoặc cập nhật cả mã nguồn theo mục 12. Riêng file này **không cần build lại hay Restart** — nó không thuộc ứng dụng đang chạy, chỉ dùng khi chạy seed.

### Bước 2. Sao lưu trước (phòng khi cần lấy lại dữ liệu cũ)

SSH vào server, kích hoạt môi trường Node (mục 6), rồi:

```bash
mkdir -p ~/sao-luu
pg_dump -U TEN_USER_DB -h localhost -Fc TEN_DATABASE -f ~/sao-luu/truoc-seed-$(date +%F).dump
```

(Tên user/database lấy trong `DATABASE_URL` của `.env.local`. Cách khôi phục: `TAI-LIEU-BAN-GIAO-VA-BAO-TRI.md` mục 4.)

### Bước 3. Seed lại

```bash
npm run db:seed
```

Thành công khi cuối log in **"Dữ liệu mẫu đã sẵn sàng"** kèm danh sách tài khoản. Seed **tự chạy luôn** phân hạng đại lý, KPI 6 tháng, quét cảnh báo và dự báo — không cần chạy `jobs:nightly` thêm.

Người đang đăng nhập sẽ bị đẩy ra trang đăng nhập (tài khoản được tạo lại), chỉ cần đăng nhập lại.

### Bước 4. Đổi mật khẩu — làm ngay sau khi seed

Seed đưa **mọi tài khoản** về mật khẩu chung `matkhau123`, vốn đã nằm công khai trong tài liệu và kho mã. Email tài khoản demo lại theo mẫu dễ đoán (`nv.hanoi@minhanh.vn`, `nv.danang@minhanh.vn`…), nên ai biết đường dẫn demo đều có thể thử.

1. **Tài khoản Ban giám đốc (bắt buộc):** đăng nhập `giamdoc@minhanh.vn` / `matkhau123` → **Tài khoản của tôi** → khung đổi mật khẩu: nhập mật khẩu hiện tại `matkhau123` và mật khẩu mới (tối thiểu 8 ký tự). (Cách ở bước 2 bên dưới cũng dùng được cho chính tài khoản Ban giám đốc.)
2. **Các tài khoản sẽ đưa cho khách dùng (bắt buộc):** vẫn đăng nhập bằng Ban giám đốc → **Danh mục → Người dùng & phân quyền** → bấm **Sửa** ở dòng người đó → ô **"Đặt lại mật khẩu"** ở cuối khung → nhập mật khẩu mới → **Đặt lại**.
3. **Các tài khoản còn lại (nên làm):** đặt lại tương tự bằng một mật khẩu dài ngẫu nhiên. Không nên dùng nút **Khoá** cho mục đích này — nhân viên bị khoá bị loại khỏi KPI, báo cáo, phân tích, ô chọn phạm vi xem và chuỗi duyệt đề xuất, làm số liệu demo bị hụt.

Đổi mật khẩu xong, mọi phiên đăng nhập cũ của tài khoản đó hết hiệu lực ngay. Gửi mật khẩu cho khách qua kênh riêng, không gửi chung một tin nhắn với đường dẫn demo.

### Bước 5. Kiểm tra nhanh

- [ ] Trang **Tổng quan** (Ban giám đốc): doanh thu tháng lớn hơn 0, ô **% kế hoạch** có số, **Đại lý lấy hàng** dạng `x/41` với x > 0.
- [ ] Trang **Khách hàng**: địa chỉ ra đúng tỉnh (ví dụ "Phường Ngô Quyền - Hải Phòng", không còn "Xã Bến Nghé - Hà Nội").
- [ ] Thử đăng nhập `giamdoc@minhanh.vn` bằng `matkhau123` → **phải bị từ chối**.
- [ ] cPanel → **Cron Jobs** vẫn còn dòng job đêm (mục 11), để cảnh báo/dự báo tự cập nhật trong những ngày sau buổi demo.

---

## Xử lý lỗi đã gặp

| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| `sh: tsx: command not found` khi chạy `npm run db:push` | `npm install` bỏ qua nhóm thư viện dev (do `NODE_ENV=production`) | `npm install --include=dev` |
| `SyntaxError: Unexpected token {` trong `tsx/dist/cli.mjs`, log có `bootstrapNodeJSCore` | Đang chạy bằng Node đời cũ của hệ thống, chưa kích hoạt môi trường Node 22 | Chạy dòng `source ...activate` (bước 6), kiểm tra `node -v` ra `v22` |
| Build báo `The global thread pool has not been initialized` / `Resource temporarily unavailable` | Hosting giới hạn số luồng của trình biên dịch | `RAYON_NUM_THREADS=1` khi build (bước 9) |
| Build báo `spawn ... node EAGAIN` ở giai đoạn thu thập dữ liệu trang | Hosting giới hạn số tiến trình con | Thêm `cpus: 1` vào `next.config.ts` trên server, hoặc dùng Cách B (build ở local) |
| Lint báo `Failed to load config "next/core-web-vitals"` | Bước lint không nạp được cấu hình trên server | Bỏ qua, dùng `--no-lint` |
| Trang trắng "404 This page could not be found" + hàng loạt file `/_next/static/...` báo 404 | Đang mở app ở đường dẫn con (`bizdev.io.vn/crm-app`) | Dùng subdomain, Application URL là `crm.bizdev.io.vn` để trống đường dẫn (bước 5) |
| Đăng nhập đúng mật khẩu nhưng quay lại trang đăng nhập, không báo lỗi | Đang vào bằng `http://` hoặc chứng chỉ SSL chưa có; cookie đăng nhập chỉ đặt qua HTTPS | Bật AutoSSL (bước 1), luôn vào bằng `https://` |
| `db:push` báo lỗi kết nối database | Sai tên DB/user/mật khẩu trong `DATABASE_URL`, hoặc mật khẩu có ký tự đặc biệt chưa mã hoá | Kiểm tra lại `.env.local`, dùng mật khẩu chỉ gồm chữ và số |
| Ứng dụng báo lỗi 500 sau khi Restart | Thường do thiếu `.next` (chưa build) hoặc thiếu biến môi trường | Xem `stderr.log` của ứng dụng (bước 10); kiểm tra `.next` có tồn tại và `.env.local` đủ các biến chính |
| Vào được nhưng "Quên mật khẩu" không gửi email | Chưa cấu hình `SMTP_URL` | Điền `SMTP_URL` trong `.env.local` rồi Restart |
| Save/Create app báo *"Cloudlinux NodeJS Selector demands to store node modules for application in separate folder..."* | Thư mục ứng dụng đã có sẵn `node_modules` thật (từ lần `npm install` chạy tay trước khi có app), cPanel cần tự tạo nó thành liên kết tượng trưng | `rm -rf node_modules` trong thư mục ứng dụng rồi Save lại; sau đó `npm install --include=dev` để cài đúng vào chỗ cPanel quản lý (bước 5) |
| Save báo *"Web application is inaccessible by its address..."* khi đổi Application URL | cPanel kiểm tra ứng dụng có phản hồi ở địa chỉ đang cấu hình trước khi cho đổi sang địa chỉ mới; ứng dụng đang dừng nên kiểm tra thất bại | Bấm Restart/Start ở địa chỉ hiện tại trước, đợi vài giây, rồi mới đổi URL; nếu vẫn lỗi thì Delete app rồi Create lại với URL đúng ngay từ đầu (bước 5) |
| *"Can't acquire lock for app: ..."* khi Save/Restart | Một thao tác trước đó (Save/Restart) bị treo, để lại khoá tầng hệ thống chưa giải phóng | Đợi vài phút rồi thử lại; kiểm tra `ps -u <user> -f \| grep -i node` xem có tiến trình của mình bị treo để tự `kill`; nếu vẫn kẹt, cần mở ticket cho HawkHost gỡ khoá (tài khoản cPanel thường không tự xoá được khoá này) |
| `node server.js` báo `Cannot find module 'react'` dù `next` đã có | Lần `npm install` gần nhất không cài xong hết (thường do bị ảnh hưởng bởi các sự cố lock/xoá-tạo-lại app ở trên) | `npm install --include=dev 2>&1 \| tee /tmp/npm-install.log` để chạy lại có ghi log, đợi chạy xong hẳn, kiểm tra `ls node_modules/react`; nếu vẫn thiếu, kiểm tra dung lượng đĩa còn trống (`df -h`) |
| Đăng nhập đúng mật khẩu nhưng trình duyệt báo *"An error occurred in the Server Components render..."*, hoặc `stderr.log` báo `relation "users" does not exist` dù `db:push`/`db:seed` qua SSH đã chạy thành công | Ứng dụng thật (qua Passenger) đang dùng giá trị `DATABASE_URL`/`DB_DRIVER` khác với `.env.local` — thường do đã thêm các biến này vào ô Environment Variables của cPanel (giá trị đó đè lên `.env.local`), và hai nơi ghi không khớp | Xoá 3 biến `DB_DRIVER`/`DATABASE_URL`/`SESSION_SECRET` khỏi ô Environment Variables của cPanel, chỉ để lại trong `.env.local` (xem ghi chú ở bước 7), Save rồi Restart |
| `db:push` báo `function gen_random_uuid() does not exist`, hoặc `CREATE EXTENSION pgcrypto` báo `could not open extension control file "...pgcrypto.control": No such file or directory` | PostgreSQL của hosting cũ hơn bản 13 (hàm `gen_random_uuid()` chưa có sẵn) và server thiếu extension `pgcrypto` ở tầng hệ điều hành — việc cài đặt này cần quyền quản trị hệ thống, ngoài tầm cPanel | Tạo hàm thay thế bằng SQL thuần, không cần extension (xem bước 8) |
| Chạy `CREATE EXTENSION pgcrypto` trong phpPgAdmin báo lỗi cú pháp `syntax error at or near "CREATE"` | Ô Query mặc định của phpPgAdmin tự bọc câu lệnh trong `SELECT COUNT(*) FROM (...)`, chỉ hợp với câu `SELECT` | Dùng mục SQL/Execute SQL riêng của phpPgAdmin, hoặc chạy qua `psql` trong SSH (đáng tin cậy hơn) |

---

## Bảo mật cần nhớ

- Không đưa `.env.local` lên nơi công khai hoặc gửi qua chat/email.
- `SESSION_SECRET` và mật khẩu database chỉ nằm trong `.env.local` trên server.
- HawkHost có sao lưu hàng ngày, nhưng vẫn nên tự xuất bản sao lưu cơ sở dữ liệu trước những lần cập nhật lớn (cPanel → Backup, hoặc `pg_dump`).
- Thư mục `crm-uploads` chứa ảnh check-in và tệp đính kèm: cần được sao lưu cùng cơ sở dữ liệu.
