Mô hình dữ liệu
Trang này dành cho người lập trình. Người dùng cuối không cần đọc.
Nguyên tắc thiết kế
Cơ sở dữ liệu phải chịu được việc mở rộng mà không phải sửa dữ liệu đã có. Hai nguyên tắc áp dụng:
1. Mở rộng bằng cách THÊM, không phải SỬA. Chỗ nào biết chắc sẽ đi từ một lên nhiều thì làm bảng nối ngay từ đầu, kể cả khi bản dùng thử chỉ cần một.
2. Ràng buộc toàn vẹn là thứ bảo vệ khả năng mở rộng. Dữ liệu bẩn mới là thứ làm chuyển đổi về sau đau đớn, không phải hình dạng bảng. Bảng nối cho phép hai người phụ trách trùng nhau thì đến ngày mở nhiều người phụ trách vẫn phải dọn dữ liệu trước.
Bốn chỗ được thiết kế sẵn cho tương lai dù bản dùng thử chưa cần:
| Chỗ | Bản dùng thử | Về sau |
|---|---|---|
task_assignees là bảng nối | Chỉ mục ép đúng 1 người phụ trách chính | Bỏ chỉ mục là có nhiều người phụ trách |
tasks.parent_id tự trỏ vào chính bảng | Luôn để rỗng | Dùng được ngay cho việc con nhiều cấp |
user_departments là bảng nối | Mỗi người đúng 1 dòng | Thêm dòng là hỗ trợ kiêm nhiệm |
notification_deliveries tách khỏi notifications | Chỉ có kênh trong phần mềm | Thêm dòng là gửi thêm qua Zalo |
Hệ quản trị
PostgreSQL 16.
| Lý do chọn | Giải thích |
|---|---|
Kiểu JSONB | Lưu dữ liệu chưa định hình được, không phải đổi cấu trúc bảng |
| Chỉ mục có điều kiện | Đánh chỉ mục riêng cho việc chưa xong, bảng lớn vẫn nhanh |
| Ràng buộc kiểm tra mạnh | Ép quy tắc nghiệp vụ ngay ở tầng dữ liệu |
| Miễn phí, chạy tốt trên máy chủ nhỏ | Phù hợp quy mô một văn phòng |
Khoá chính dùng bigint tự tăng.
Múi giờ — bắt buộc đặt trước khi tạo bảng
ALTER DATABASE adang SET TimeZone = 'Asia/Ho_Chi_Minh';
Ảnh máy chủ PostgreSQL mặc định chạy giờ UTC. Nếu để mặc định, mọi phép so sánh giữa due_date (kiểu date) và submitted_at (kiểu timestamptz) sẽ lệch 7 tiếng, khiến BR-08 tính sai ai trễ hạn.
Định nghĩa chuẩn: hạn hoàn thành nghĩa là hết ngày due_date theo giờ Việt Nam. Mọi truy vấn liên quan phải viết tường minh, không dựa vào ép kiểu ngầm:
-- Việc trễ hạn
WHERE due_date IS NOT NULL
AND status NOT IN ('done','cancelled')
AND now() >= ((due_date + 1)::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh');
-- Nhân viên nộp đúng hạn (BR-08, trường hợp 2)
WHERE submitted_at < ((due_date + 1)::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh');
Sơ đồ quan hệ
organizations ──┬── departments ──┐ (tự trỏ: Trường → Phòng → Tổ)
│ │ │
│ ├────────┴── user_departments ── users ──┬── user_sessions
│ │ │ │
│ │ roles │
│ │ │
└────── tasks ──┐ (tự trỏ: việc → việc con) │
│ │ │
├──────┴── task_assignees ────────────────┤
├── task_comments ────────────────────────┤
├── task_attachments ─────────────────────┤
├── task_status_history ──────────────────┤
└── notifications ── notification_deliveries
activity_log (ghi vết mọi thay đổi, không khoá ngoại cứng)
Nhiều trường dùng chung hệ thống
Khách hàng là cán bộ ở sở, nên khả năng nhiều trường cùng dùng một hệ thống là kịch bản thật chứ không phải giả định xa vời.
Cách rẻ nhất là đặt organization_id ngay từ bản dùng thử, với đúng một dòng trong bảng organizations. Chi phí lúc này gần bằng không. Nếu để sau mới thêm thì phải điền ngược giá trị cho toàn bộ dữ liệu cũ và đổi ràng buộc duy nhất trên email — đó đúng là đợt chuyển đổi mà thiết kế này muốn tránh.
organizations
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| name | varchar(255) | Tên trường |
| code | varchar(50) | Duy nhất, dùng trong đường dẫn |
| status | varchar(20) | |
| created_at, updated_at | timestamptz |
organization_id là cột bắt buộc trên users, departments và tasks. Ba bảng này là gốc của mọi truy vấn phân quyền.
Phạm vi của Ban Giám hiệu là "toàn trường", không phải "không lọc gì". Ngay cả khi hệ thống chỉ có một trường, tầng phân quyền vẫn phải giới hạn theo organization_id của người đăng nhập. Viết "BGH thì bỏ qua bộ lọc" là lỗ hổng ngủ đông: ngày thêm trường thứ hai, Ban Giám hiệu trường A sẽ nhìn thấy toàn bộ công việc của trường B.
Cây phòng ban
Bảng departments tự trỏ vào chính nó qua parent_id:
Trường THPT X (parent_id = NULL, cấp 0)
├── Văn phòng Hành chính (cấp 1) ← bản dùng thử triển khai ở đây
├── Phòng Đào tạo (cấp 1)
│ ├── Tổ Toán (cấp 2)
│ └── Tổ Văn (cấp 2)
└── Phòng Tài vụ (cấp 1)
Cấu trúc này cho phép mở ra phòng ban khác và thêm tổ chuyên môn dưới phòng mà không đổi bảng — chỉ là thêm dòng dữ liệu.
Duyệt cây bằng truy vấn đệ quy sẵn có của PostgreSQL:
WITH RECURSIVE sub AS (
SELECT id FROM departments WHERE id = $1
UNION ALL
SELECT d.id FROM departments d JOIN sub ON d.parent_id = sub.id
)
SELECT id FROM sub;
Bản thảo đầu dùng kiểu ltree để truy vấn cả nhánh trong một lần. Đã bỏ, vì ba lý do:
- Prisma không hỗ trợ
ltree— cột sẽ thànhUnsupported, phải viết truy vấn thô cho mọi phép tính phạm vi. Mất đúng cái lợi đã chọn Prisma vì nó. - Hai nguồn sự thật cho một cấu trúc.
pathvàparent_idphải luôn khớp nhau, cần trigger đồng bộ cả cây con mỗi lần đổi cha. Quên một lần là phân quyền sai âm thầm, không báo lỗi. - Bảng này chỉ có khoảng 5–15 dòng. Truy vấn đệ quy chạy dưới một phần nghìn giây.
Khi nào cây thật sự sâu và bảng lớn, thêm cột path là thao tác thêm — đúng nguyên tắc, và lúc đó làm được dạng cột tự sinh.
Ràng buộc chống vòng lặp:
ALTER TABLE departments ADD CONSTRAINT chk_dept_no_self_parent
CHECK (parent_id IS NULL OR parent_id <> id);
Ràng buộc này chỉ chặn phòng tự làm cha chính nó. Chu trình dài hơn (A → B → A) phải kiểm ở tầng dịch vụ mỗi lần đổi parent_id, vì PostgreSQL không diễn đạt được ràng buộc đó bằng CHECK.
Chi tiết các bảng
users
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| organization_id | bigint | Bắt buộc |
| varchar(255) | Xem ràng buộc duy nhất bên dưới | |
| password_hash | varchar(255) | Mã hoá bằng bcrypt |
| full_name | varchar(255) | |
| phone | varchar(20) | Cho phép rỗng |
| zalo_user_id | varchar(64) | Cho phép rỗng, để sẵn cho giai đoạn sau |
| status | varchar(20) | active hoặc disabled |
| last_login_at | timestamptz | |
| created_at, updated_at | timestamptz |
-- Email không phân biệt hoa thường, duy nhất trong phạm vi một trường
CREATE UNIQUE INDEX uq_users_org_email
ON users (organization_id, lower(email));
ALTER TABLE users ADD CONSTRAINT chk_user_status
CHECK (status IN ('active','disabled'));
So sánh varchar trong PostgreSQL phân biệt hoa thường. Không chuẩn hoá thì [email protected] và [email protected] thành hai tài khoản khác nhau, và cán bộ gõ email viết hoa chữ đầu — điện thoại tự động viết hoa — sẽ không đăng nhập được dù mật khẩu đúng. Tầng dịch vụ phải chuẩn hoá bằng lower(trim(email)) cả khi tạo lẫn khi đăng nhập.
Tài khoản bị khoá thì đổi status, không xoá dòng.
departments
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| organization_id | bigint | Bắt buộc |
| parent_id | bigint | Tự trỏ. Rỗng nghĩa là gốc cây |
| name | varchar(255) | |
| status | varchar(20) | |
| created_at, updated_at | timestamptz |
roles
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| code | varchar(50) | bgh, truong_phong, nhan_vien — duy nhất |
| name | varchar(100) | Tên hiển thị tiếng Việt |
ALTER TABLE roles ADD CONSTRAINT uq_roles_code UNIQUE (code);
Thiếu ràng buộc duy nhất này thì hai dòng cùng mã truong_phong sẽ khiến phân quyền hoạt động tuỳ theo dòng nào được đọc trước.
Vai trò để trong bảng riêng thay vì kiểu liệt kê cố định. Thêm vai trò mới chỉ là thêm một dòng.
user_departments
| Cột | Kiểu | Ghi chú |
|---|---|---|
| user_id | bigint | |
| department_id | bigint | |
| role_id | bigint | |
| is_primary | boolean | NOT NULL, mặc định true |
| created_at | timestamptz |
ALTER TABLE user_departments ADD PRIMARY KEY (user_id, department_id);
CREATE UNIQUE INDEX uq_user_primary_dept
ON user_departments (user_id) WHERE is_primary = true;
Khoá chính là cặp (user_id, department_id), không gồm role_id. Nếu đưa role_id vào khoá chính thì một người có thể vừa là nhan_vien vừa là truong_phong trong cùng một phòng — tầng phân quyền nhận về hai dòng và hành vi phụ thuộc dòng nào đọc trước. Đó là lỗ hổng nâng quyền im lặng. Ngoài ra đổi vai trò sẽ phải xoá rồi thêm lại, mất mốc thời gian.
Bản dùng thử: mỗi người đúng một dòng, is_primary = true. Giao diện chỉ cho chọn một phòng.
Về sau: hỗ trợ kiêm nhiệm thì thêm dòng thứ hai với is_primary = false. Cấu trúc không đổi.
tasks
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| organization_id | bigint | Bắt buộc |
| department_id | bigint | Phòng sở hữu việc |
| parent_id | bigint | Tự trỏ. Bản dùng thử luôn rỗng |
| title | varchar(500) | |
| description | text | |
| status | varchar(30) | new, in_progress, pending_review, done, cancelled |
| priority | varchar(20) | normal, urgent |
| due_date | date | Cho phép rỗng theo BR-04 |
| board_position | integer | Cho phép rỗng. Để sẵn cho bảng kéo thả (FR-VIEW-05) |
| created_by | bigint | Người giao việc |
| submitted_at | timestamptz | Lúc chuyển sang Chờ xác nhận |
| completed_at | timestamptz | Lúc được xác nhận hoàn thành |
| cancelled_at | timestamptz | |
| cancel_reason | text | |
| lock_version | integer | Đếm số lần sửa, phục vụ chống ghi đè khi hai người cùng thao tác |
| metadata | jsonb | Dữ liệu phát sinh chưa định hình |
| created_at, updated_at | timestamptz |
Giữ department_id trên chính công việc thay vì suy ra từ phòng của người phụ trách. Nhờ vậy nhân viên chuyển phòng thì việc cũ vẫn thuộc phòng cũ, đúng như quy định trong ma trận phân quyền.
submitted_at phục vụ BR-08: có cột này mới phân biệt được nhân viên nộp muộn với người giao việc duyệt muộn.
Khi người giao trả lại việc (Chờ xác nhận → Đang làm), phải đặt submitted_at về rỗng.
Giữ nguyên giá trị cũ sẽ khiến một việc đã bị trả lại, đang nằm ở nhân viên và đã quá hạn, bị đếm nhầm là chờ người giao duyệt. Hậu quả: ô đếm trên màn hình tổng quan sai, và thông báo quá hạn chỉ gửi cho trưởng phòng còn nhân viên không nhận.
Đây là lỗi âm thầm — không báo lỗi, không ai phát hiện cho tới khi nhìn báo cáo cuối tháng.
Nhân viên nộp lại lần sau thì ghi đè submitted_at bằng thời điểm mới.
Dữ liệu nào cần lọc hoặc thống kê thì phải làm thành cột thật, không nhét vào metadata.
task_assignees
| Cột | Kiểu | Ghi chú |
|---|---|---|
| task_id | bigint | |
| user_id | bigint | |
| is_primary | boolean | NOT NULL, mặc định true |
| assigned_by | bigint | Ai giao |
| assigned_at | timestamptz |
ALTER TABLE task_assignees ADD PRIMARY KEY (task_id, user_id);
ALTER TABLE task_assignees ALTER COLUMN is_primary SET NOT NULL;
ALTER TABLE task_assignees ALTER COLUMN is_primary SET DEFAULT true;
-- Ép quy tắc BR-02: mỗi việc nhiều nhất một người phụ trách chính
CREATE UNIQUE INDEX uq_task_primary_assignee
ON task_assignees (task_id) WHERE is_primary = true;
Bốn ràng buộc trên đều cần thiết, thiếu một cái là mở một lỗ:
| Thiếu | Hậu quả |
|---|---|
Khoá chính (task_id, user_id) | Cùng một người gán hai lần vào một việc. Màn hình Việc của tôi hiện trùng, báo cáo đếm gấp đôi |
is_primary NOT NULL | Giá trị rỗng không lọt vào chỉ mục có điều kiện, quy tắc một người bị vô hiệu |
is_primary DEFAULT true | Chèn thiếu cột sẽ tạo dòng không phải người phụ trách chính |
| Chỉ mục duy nhất | Một việc có nhiều người phụ trách chính |
Chỉ mục này ép "nhiều nhất một", không ép "đúng một". Một việc có 0 người phụ trách vẫn hợp lệ với cơ sở dữ liệu, và việc đó sẽ không hiện trong màn hình của ai, không ai nhận thông báo — rơi vào im lặng. Cơ sở dữ liệu không diễn đạt được ràng buộc này, nên tầng dịch vụ phải tạo công việc và người phụ trách trong cùng một giao dịch.
Tương tự, cơ sở dữ liệu không chặn được việc gán người của phòng khác. Tầng dịch vụ phải kiểm task.department_id nằm trong phạm vi phòng của người được gán. Bỏ qua bước này là đường rò dữ liệu giữa các phòng, vì phạm vi của nhân viên tính theo task_assignees chứ không theo phòng ban.
Khi mở nhiều người phụ trách, chỉ cần bỏ uq_task_primary_assignee. Khoá chính vẫn giữ dữ liệu sạch.
task_comments
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| task_id | bigint | |
| user_id | bigint | |
| content | text | |
| deleted_at | timestamptz | Xoá mềm, giữ vết |
| created_at, updated_at | timestamptz |
task_attachments
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| task_id | bigint | |
| uploaded_by | bigint | |
| file_name | varchar(500) | Tên gốc người dùng thấy |
| storage_path | varchar(1000) | Đường dẫn thật trên máy chủ |
| file_size | bigint | Byte |
| mime_type | varchar(100) | |
| deleted_at | timestamptz | Xoá mềm |
| deleted_by | bigint | |
| created_at | timestamptz |
Tệp đính kèm cũng xoá mềm như bình luận. Xoá cứng sẽ mất dấu vết việc tệp từng tồn tại, trái với BR-03 và NFR-SEC-06. Trường hợp cần xoá thật — ví dụ đính nhầm tài liệu nhạy cảm — là thao tác riêng của người quản trị, có ghi vết.
Tách tên hiển thị khỏi đường dẫn lưu trữ để sau này đổi nơi lưu mà không đổi bảng.
task_status_history
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| task_id | bigint | |
| from_status, to_status | varchar(30) | Rỗng nếu là lúc vừa tạo |
| from_assignee_id, to_assignee_id | bigint | Phục vụ BR-09 |
| from_due_date, to_due_date | date | Ghi lại mỗi lần đổi hạn |
| changed_by | bigint | |
| note | text | |
| created_at | timestamptz |
Hai cột from_due_date / to_due_date quan trọng hơn vẻ ngoài của chúng. Không có chúng thì cuối tháng một người có thể sửa hạn của tám việc trễ sang tháng sau, con số "Trễ hạn" về không, và không nơi nào truy vấn được điều đó đã xảy ra. Đây cũng là dữ liệu nền cho chức năng xin gia hạn ở giai đoạn sau.
notifications và notification_deliveries
Tách làm hai bảng: nội dung thông báo và từng lần gửi qua mỗi kênh.
notifications:
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| user_id | bigint | Người nhận |
| task_id | bigint | Cho phép rỗng |
| type | varchar(50) | Tám loại, xem ràng buộc bên dưới |
| title | varchar(500) | |
| body | text | |
| read_at | timestamptz | Rỗng nghĩa là chưa đọc |
| dedup_key | varchar(200) | Khoá chống trùng |
| created_at | timestamptz |
notification_deliveries:
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| notification_id | bigint | |
| channel | varchar(20) | in_app. Sau thêm zalo, email |
| sent_at | timestamptz | Rỗng nghĩa là chưa gửi |
| error | text | Lý do gửi hỏng |
CREATE UNIQUE INDEX uq_notif_dedup
ON notifications (dedup_key) WHERE dedup_key IS NOT NULL;
ALTER TABLE notification_deliveries
ADD CONSTRAINT uq_delivery UNIQUE (notification_id, channel);
Vì sao tách hai bảng. Nếu để channel ngay trên dòng thông báo thì gửi cùng một sự kiện qua cả trong phần mềm lẫn Zalo phải tạo hai dòng — người dùng thấy hai thông báo trùng, đánh dấu đã đọc cái này không tắt cái kia, và số đếm chưa đọc bị nhân đôi. Trạng thái đã đọc thuộc về người, không thuộc về kênh. Đây chính là mẫu bảng nối đã áp dụng cho task_assignees.
Vì sao cần dedup_key. Tác vụ quét chạy 15 phút một lần. Không có khoá chống trùng thì mỗi việc quá hạn sinh 96 thông báo mỗi ngày cho mỗi người liên quan. Một việc bị quên trong 30 ngày tạo khoảng 5.700 dòng, ô đếm chưa đọc thành con số vô nghĩa và người dùng tắt thông báo ngay tuần đầu.
-- Tác vụ định kỳ chèn theo cách này
INSERT INTO notifications (..., dedup_key)
VALUES (..., 'overdue:task=42:user=7:2026-08-05')
ON CONFLICT (dedup_key) DO NOTHING;
user_sessions
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| user_id | bigint | |
| token_hash | varchar(255) | Duy nhất |
| last_seen_at | timestamptz | Cập nhật mỗi lần có thao tác |
| expires_at | timestamptz | |
| revoked_at | timestamptz | |
| ip_address | inet | |
| created_at | timestamptz |
Bảng này cần cho ba việc mà mã thông hành đứng một mình không làm được:
| Yêu cầu | Vì sao cần bảng |
|---|---|
| NFR-SEC-03 — hết hạn sau 8 giờ không thao tác | Mã thông hành có hạn cố định, không biết người dùng có đang thao tác hay không. Cần last_seen_at |
| Đăng xuất thật sự | Không có bảng thì mã thông hành bị lộ vẫn dùng được tới khi hết hạn, dù đã bấm đăng xuất |
| Khoá tài khoản có hiệu lực ngay | Khoá tài khoản phải đá được phiên đang mở ra |
activity_log
| Cột | Kiểu | Ghi chú |
|---|---|---|
| id | bigserial | |
| actor_id | bigint | Ai thao tác. Cho phép rỗng — tác vụ định kỳ không có người thao tác |
| entity_type | varchar(50) | task, user, department |
| entity_id | bigint | |
| action | varchar(50) | create, update, delete, login |
| changes | jsonb | Giá trị trước và sau |
| ip_address | inet | |
| created_at | timestamptz |
Không đặt khoá ngoại cứng vào entity_id để ghi được cho bảng bất kỳ, kể cả bảng thêm về sau.
password_hash, token_hash, và mọi giá trị bí mật khác.
Cách cài đặt tự nhiên nhất là so sánh toàn bộ dòng trước và sau rồi ghi phần khác nhau. Làm vậy thì PATCH /users/:id và đặt lại mật khẩu sẽ đẩy mã băm mật khẩu vào một bảng không có phân quyền theo phòng ban. Phải lọc danh sách cột này trước khi ghi.
Chỉ mục
-- Danh sách việc theo phòng và trạng thái (truy vấn hay dùng nhất)
CREATE INDEX idx_tasks_dept_status ON tasks (department_id, status);
-- Việc trễ hạn: chỉ đánh chỉ mục việc chưa xong, bảng lớn vẫn nhẹ
CREATE INDEX idx_tasks_due_open ON tasks (due_date)
WHERE status NOT IN ('done','cancelled') AND due_date IS NOT NULL;
-- Màn hình Việc của tôi
CREATE INDEX idx_assignees_user ON task_assignees (user_id);
-- Khoá ngoại phía tham chiếu — PostgreSQL KHÔNG tự tạo
CREATE INDEX idx_comments_task ON task_comments (task_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_attach_task ON task_attachments (task_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_history_task ON task_status_history (task_id, created_at);
-- Thông báo
CREATE INDEX idx_notif_unread ON notifications (user_id) WHERE read_at IS NULL;
CREATE INDEX idx_notif_user_time ON notifications (user_id, created_at DESC);
-- Ghi vết: bảng lớn nhất hệ thống
CREATE INDEX idx_alog_entity ON activity_log (entity_type, entity_id, created_at DESC);
CREATE INDEX idx_alog_actor ON activity_log (actor_id, created_at DESC);
-- Phiên đăng nhập
CREATE INDEX idx_sessions_user ON user_sessions (user_id) WHERE revoked_at IS NULL;
PostgreSQL không tự tạo chỉ mục cho phía tham chiếu của khoá ngoại. Thiếu chúng thì mỗi lần mở màn hình chi tiết một công việc là ba lần quét toàn bảng. activity_log ghi vết mọi thao tác nên sẽ là bảng lớn nhất — không có chỉ mục thì mọi truy vấn truy vết đều quét toàn bộ.
Hai chỉ mục có điều kiện giữ hiệu năng khi dữ liệu tích luỹ nhiều năm: việc đã xong chiếm phần lớn số dòng nhưng không bao giờ bị hỏi tới trong hai truy vấn đó.
Ràng buộc ép quy tắc nghiệp vụ
Đặt ràng buộc ngay ở cơ sở dữ liệu, không chỉ kiểm tra trong mã nguồn. Mã nguồn có thể sót đường đi, cơ sở dữ liệu thì không.
ALTER TABLE tasks ADD CONSTRAINT chk_task_status
CHECK (status IN ('new','in_progress','pending_review','done','cancelled'));
ALTER TABLE tasks ADD CONSTRAINT chk_task_priority
CHECK (priority IN ('normal','urgent'));
ALTER TABLE tasks ADD CONSTRAINT chk_done_has_time
CHECK (status <> 'done' OR completed_at IS NOT NULL);
ALTER TABLE tasks ADD CONSTRAINT chk_cancelled_has_time
CHECK (status <> 'cancelled' OR cancelled_at IS NOT NULL);
ALTER TABLE tasks ADD CONSTRAINT chk_no_self_parent
CHECK (parent_id IS NULL OR parent_id <> id);
ALTER TABLE tasks ALTER COLUMN department_id SET NOT NULL;
ALTER TABLE tasks ALTER COLUMN lock_version SET DEFAULT 0;
ALTER TABLE notifications ADD CONSTRAINT chk_notif_type
CHECK (type IN ('task_assigned','task_reassigned','task_returned',
'task_completed','task_cancelled','comment_added',
'due_soon','overdue'));
ALTER TABLE notification_deliveries ADD CONSTRAINT chk_channel
CHECK (channel IN ('in_app','zalo','email'));
ALTER TABLE departments ADD CONSTRAINT chk_dept_status
CHECK (status IN ('active','disabled'));
Mọi khoá ngoại đặt ON DELETE RESTRICT, phù hợp với BR-03: hệ thống không xoá vĩnh viễn dữ liệu nào.
Cách mở rộng cho 6 chức năng giai đoạn sau
Đây là cách kiểm chứng NFR-MAINT-03. Bảng liệt kê đúng 6 chức năng mang mức ưu tiên "Giai đoạn sau" trong danh sách chức năng.
| Mã | Chức năng | Việc cần làm | Chuyển dữ liệu cũ |
|---|---|---|---|
| FR-TASK-10 | Danh sách mục con trong việc | Thêm 2 bảng task_checklists, task_checklist_items | Không |
| FR-TASK-11 | Xin gia hạn | Thêm 1 bảng task_extension_requests. Lịch sử đổi hạn đã có sẵn trong task_status_history | Không |
| FR-TASK-12 | Việc con, duyệt nhiều bước | Dùng tasks.parent_id có sẵn | Không |
| FR-VIEW-05 | Bảng kéo thả | Dùng cột tasks.board_position có sẵn | Không |
| FR-VIEW-06 | Báo cáo khối lượng theo người | Truy vấn trên task_status_history có sẵn | Không |
| FR-NOTI-04 | Thông báo qua Zalo | Thêm dòng channel = 'zalo' vào notification_deliveries | Không |
Ngoài 6 chức năng trên, việc nới quy tắc BR-02 cho nhiều người phụ trách cũng không cần chuyển dữ liệu: chỉ bỏ chỉ mục uq_task_primary_assignee. Đây không phải một chức năng trong danh sách 26 mã, mà là hệ quả của việc đổi quy tắc nghiệp vụ.
Những gì cố tình chưa làm
| Không làm | Vì sao | Khi nào xem lại |
|---|---|---|
Kiểu ltree cho cây phòng ban | Prisma không hỗ trợ, bảng chỉ 5–15 dòng | Khi cây vượt 5 cấp hoặc trên 500 phòng ban |
| Tìm kiếm bỏ dấu tiếng Việt | Bản dùng thử chấp nhận tìm có dấu bằng ILIKE | Khi người dùng phàn nàn, thêm unaccent + pg_trgm |
Phân vùng bảng activity_log theo tháng | Chưa đủ dữ liệu để cần | Khi vượt 10 triệu dòng |
| Khoá tài khoản sau N lần đăng nhập sai | Bản dùng thử chặn tạm theo địa chỉ mạng ở tầng ứng dụng, đủ cho mạng nội bộ | Khi mở ra ngoài mạng nội bộ — khi đó thêm cột failed_login_count và locked_until vào users |
Quy ước chuyển đổi cấu trúc
- Mỗi lần đổi cấu trúc là một tệp riêng, đánh số tăng dần, không sửa tệp đã chạy
- Thêm cột mới phải cho phép rỗng hoặc có giá trị mặc định, để chạy được khi hệ thống đang hoạt động
- Không xoá cột ngay: đánh dấu ngừng dùng, để ít nhất một đợt phát hành rồi mới xoá
- Mỗi tệp phải viết được phần quay lui