Development Rules
MANDATORY — Mọi contributor (human & AI) PHẢI tuân thủ. Vi phạm = block PR, không merge.
0. Engineering Mindset (CRITICAL)
0.1 Senior developer stance — mặc định
Mọi contributor (đặc biệt AI agents) PHẢI tự đặt mình vào vai senior developer khi đánh giá yêu cầu và lên kế hoạch:
- Đánh giá trước khi gật đầu — nếu requirement có lỗ hổng, rủi ro, hoặc mâu thuẫn với docs/architecture, PHẢI flag ngay, không implement blind.
- Hỏi tại sao, không hỏi làm thế nào — tìm root cause trước khi đề xuất giải pháp.
- Lock decisions trước khi code — mọi quyết định kiến trúc, data model, security boundary PHẢI được confirm explicit trước Phase 3 (PLAN) kết thúc. Không "sau này tính".
- Challenge khi recommendation sai context — ví dụ: planner đề xuất
@Intervalkhi Redis đã là hard dependency → phải nói "không đúng, dùng BullMQ". Người tốt không nói "yes" với mọi thứ. - Tính đến scale + failure mode — không chỉ happy path. Hỏi: "Nếu crash giữa bước X và Y thì sao? Horizontally scale được không? Race condition ở đâu?"
0.2 Payment-sensitive discipline — ZERO "sau này update"
Payment và tất cả code chạm vào money/credentials/refund/audit-log thuộc high-stakes domain. Áp dụng nguyên tắc sau, không ngoại lệ:
- KHÔNG TODO, KHÔNG
// fix later, KHÔNG placeholder trong code merged vàofeat/payment-*hoặc main. - KHÔNG "MVP shortcut" kiểu: bỏ observability, bỏ retry, bỏ idempotency check, hardcode value "tạm", skip tenant scope "for now". Mọi shortcut = regression waiting to happen.
- KHÔNG defer compliance/security sang phase sau — PCI-DSS boundary, AES-GCM crypto, HMAC verify, tenant isolation, audit log PHẢI đúng ngay từ commit đầu tiên.
- KHÔNG silent error — mọi failure path phải log, có error code stable, có retry hoặc dead-letter queue, không swallow exception.
- KHÔNG "sau này thêm test" — payment code vào repo KHÔNG có test = revert commit.
- Observability = required part of feature — counter/metric/log cho mọi state transition + failure path, không phải "observability epic riêng".
- Financial invariants PHẢI có unit test explicit —
refunded ≤ captured ≤ authorized, currency consistency, amount non-negative, idempotency key uniqueness.
Lý do: Payment bug = tiền thật, khách thật, legal liability thật. "Sau này fix" đồng nghĩa tiền đã mất/double-charged rồi. Cost of fixing in production ≫ cost of doing it right the first time.
Đọc ../architecture/payment-architecture.md section 9 (Security & Compliance) và ../flows/payment-flow.md (Error Codes + Flow 15 retry) trước khi code bất cứ thứ gì trong core/payment/.
0.3 Checklist trước khi bắt đầu Phase 3 (PLAN)
- Đã đọc hết docs liên quan (architecture + flow + api-design)?
- Đã list explicit mọi decision cần lock? (nếu >0 decision còn ambiguous → không bắt đầu code)
- Đã identify failure modes + mitigation cho từng cái?
- Có part nào đang nghĩ "để sau" không? Nếu có → move vào scope HOẶC document tại sao defer OK (phải có lý do kỹ thuật, không phải "làm cho nhanh")
- Task có chạm payment/auth/credentials/money? Nếu có → áp dụng §0.2 NO SHORTCUTS, re-read §9 payment-architecture.md
1. Git Discipline
KHÔNG được
- KHÔNG commit rác: file thừa, console.log, commented-out code, TODO không có issue
- KHÔNG commit trực tiếp lên
main— luôn qua PR - KHÔNG force push lên shared branches
- KHÔNG commit
.env, secrets, hoặc credentials - KHÔNG commit generated files:
dist/,node_modules/,generated/,openapi.json - KHÔNG amend commits đã push
Commit rules
- Mỗi commit PHẢI atomic — 1 commit = 1 thay đổi logic
- Commit message PHẢI follow conventional commits:
feat:,fix:,refactor:,test:,chore:,docs: - Message tiếng Anh, ngắn gọn, mô tả "what & why" không phải "how"
- Pre-commit hook sẽ tự chạy lint + format — nếu fail thì FIX, không skip
Branch naming
feat/US-{number}-{short-description}
fix/{short-description}
refactor/{short-description}
test/{short-description}
chore/{short-description}
2. Testing (CRITICAL)
Rule: Mọi API endpoint PHẢI có test TRƯỚC khi merge
| Loại | Scope | Coverage target |
|---|---|---|
| Unit test | Service methods, utils, guards | 80%+ |
| Integration test | Controller + Service + DB (real Prisma) | Mọi endpoint |
| E2E test | Critical user flows | Happy path + error cases |
Test file convention
src/core/booking/booking.service.ts → booking.service.spec.ts
src/core/booking/booking.controller.ts → booking.controller.spec.ts
test/booking.e2e-spec.ts → E2E tests
Mỗi endpoint PHẢI test
- Happy path: input hợp lệ → response đúng format (envelope)
- Validation: input sai → 400 + error details
- Not found: ID không tồn tại → 404
- Conflict: business rule violation → 409/422
- Auth: unauthorized → 401, forbidden → 403
- Tenant isolation: không thấy data tenant khác
- Pagination: page/limit params, meta response
Test TRƯỚC, code SAU (TDD)
1. Viết test → RED (fail)
2. Viết implementation → GREEN (pass)
3. Refactor → CLEAN
4. Verify coverage ≥ 80%
KHÔNG được
- KHÔNG skip tests khi thêm/sửa endpoint
- KHÔNG mock database trong integration tests — dùng real test DB
- KHÔNG viết test chỉ test happy path — phải cover error cases
- KHÔNG commit code với test fail
3. Code Quality
Zero tolerance
| Issue | Action |
|---|---|
console.log trong production code |
REMOVE — dùng NestJS Logger |
| Commented-out code | REMOVE — git có history |
| Unused imports | REMOVE — ESLint sẽ catch |
| Unused variables | REMOVE — prefix _ nếu intentional (callback params) |
| Dead functions/classes | REMOVE — không giữ "just in case" |
| Magic numbers | EXTRACT thành named constant |
| Hardcoded strings (URLs, config) | MOVE vào env hoặc config |
any type |
AVOID — chỉ dùng khi Prisma type mismatch, phải kèm eslint-disable comment giải thích lý do |
| Duplicate code | EXTRACT thành shared util nếu ≥ 3 lần |
File size
- Max 400 lines cho service/controller files
- Max 200 lines cho DTO/util files
- Max 800 lines absolute — nếu vượt thì PHẢI split
Function size
- Max 50 lines per function
- Nếu dài hơn → extract thành private methods
- Tên function phải self-documenting — KHÔNG cần comment giải thích "what"
Naming
| Element | Convention | Example |
|---|---|---|
| File | kebab-case | booking.service.ts |
| Class | PascalCase | BookingService |
| Method/function | camelCase | findAllByTenant |
| Variable | camelCase | tenantId |
| Constant | UPPER_SNAKE_CASE | MAX_PAGE_LIMIT |
| Enum value | UPPER_SNAKE_CASE | BookingStatus.CONFIRMED |
| DB column | snake_case (via @map) | tenant_id |
| API URL | kebab-case | /service-categories |
4. ESLint & Prettier (Auto-enforced)
ESLint rules (strict)
- No unused vars — error, không phải warning
- No explicit any — warn (allow khi có eslint-disable comment)
- No floating promises — error, phải await hoặc void
- No console — error trong src/, allow trong test/
- Consistent return — mọi branch phải return hoặc không
- Prefer const — không dùng
letnếu không reassign - No var — chỉ dùng const/let
Prettier rules
- Single quotes
- Trailing commas (all)
- Print width: 100
- Tab width: 2
- Semicolons: always
Auto-fix
- Pre-commit hook (Husky + lint-staged): auto format + lint trước mỗi commit
- Nếu lint fail → commit bị block → FIX rồi commit lại
- KHÔNG dùng
--no-verifyđể bypass hooks
5. Dependency Management
Rules
- Yarn only — không npm/npx
- KHÔNG add dependency mà không có lý do rõ ràng
- Prefer NestJS built-in modules trước khi thêm 3rd party
- Pin major versions trong package.json (
^cho minor, không*) - KHÔNG commit
yarn.lockchanges không liên quan đến task
Banned patterns
moment.js→ dùngdate-fnshoặc native Datelodash(full) → dùng native JS hoặclodash/specific-functionaxios→ dùng NestJS HttpModule (built-in)
6. Frontend API Consumption
Response Envelope
Mọi API response đều wrapped trong envelope {success, data, error, meta}.
api.get<T>() trả ApiResponse<T> — client PHẢI unwrap bằng .data:
// Paginated list — API trả {success, data: Resource[], meta: {total, page, limit}}
const { data } = useQuery({
queryKey: ['staff'],
queryFn: () => api.get<Resource[]>('/resources?limit=100'),
});
const staff = data?.data ?? []; // Resource[]
const meta = data?.meta; // {total, page, limit}
// Single item
const { data } = useQuery({
queryKey: ['tenant', id],
queryFn: () => api.get<Tenant>(`/tenants/${id}`),
select: (res) => res.data ?? null,
});
KHÔNG BAO GIỜ
- KHÔNG
res.data?.data?.data— chỉ cần 1 level.datasauApiResponse - KHÔNG tự define response wrapper type (vd
{ data: T[]; meta: unknown }) —ApiResponse<T>đã xử lý - KHÔNG dùng type khác cho cùng endpoint ở các component khác nhau
Pattern chuẩn cho hooks
// List hook
export function useStaffList() {
return useQuery({
queryKey: ['staff'],
queryFn: () => api.get<Resource[]>('/resources?limit=100'),
select: (res) => res.data ?? [],
});
}
// Mutation hook
export function useCreateStaff(opts: { successMessage: string; onSuccess?: () => void }) {
const qc = useQueryClient();
return useFormMutation({
mutationFn: (data: Record<string, unknown>) => api.post<Resource>('/resources', data),
successMessage: opts.successMessage,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['staff'] });
opts.onSuccess?.();
},
});
}
7. API Implementation Checklist
Trước khi tạo PR cho bất kỳ endpoint nào:
Code
- Follow
docs/architecture/api-design.md— response envelope, error codes, etc. - DTO với class-validator + Swagger decorators
- Tenant-scoped (filter by tenantId)
- Auth guard + Role guard
- Service layer xử lý business logic (controller chỉ delegate)
Tests
- Unit tests cho service methods
- Integration tests cho controller endpoints
- Happy path + error cases + edge cases
- Tenant isolation test
- Coverage ≥ 80%
Quality
-
yarn lintpasses (zero errors) -
yarn buildpasses -
yarn testpasses - No
console.log - No commented-out code
- No unused imports/variables
- No magic numbers
- File size within limits
7. PR Rules
PR size
- Max 400 lines changed (excluding generated files, tests)
- Nếu lớn hơn → split thành multiple PRs
- Exception: initial scaffold, migration files
PR description PHẢI có
- Summary (what & why)
- Link to user story (US-X.X)
- Test plan
- Screenshots (nếu UI changes)
Review checklist
- Code follows development rules
- Tests adequate
- No security issues
- No performance concerns
- API design compliant
Merge policy
- Require ≥ 1 approval
- All checks pass (lint, build, test)
- No merge with unresolved comments
- Squash merge to keep history clean
8. Date & Time — Timezone Rules (CRITICAL)
Vi phạm timezone rules = data corruption. Không có ngoại lệ.
Core Principle
┌─────────────────────────────────────────────────────────────┐
│ Mọi thời gian user nhìn thấy = giờ SALON (Europe/Oslo) │
│ Mọi thời gian lưu DB = UTC │
│ Timezone của user (browser) = KHÔNG BAO GIỜ được dùng │
└─────────────────────────────────────────────────────────────┘
Data Types & Storage
| Data | Kiểu | Ví dụ | Timezone |
|---|---|---|---|
| Business hours | String "HH:mm" |
"09:00" |
Salon local — KHÔNG convert |
| Resource schedule | String "HH:mm" |
"09:00" |
Salon local — KHÔNG convert |
| Schedule override time | String "HH:mm" nullable |
"10:00" / null |
Salon local |
| Schedule override date | @db.Date |
2026-04-14 |
Calendar date (no tz) |
| Booking startTime/endTime | DateTime |
2026-04-14T07:00:00Z |
UTC |
| Tenant timezone | String IANA | "Europe/Oslo" |
Config — source of truth |
Conversion Rules
Khi LƯU booking (frontend → API):
// Frontend: user chọn 09:00 ngày 14/04 → convert salon local → UTC
import { toUTC } from '@/lib/timezone';
const utcISO = toUTC('2026-04-14', '09:00', 'Europe/Oslo');
// → "2026-04-14T07:00:00.000Z" (Oslo UTC+2 summer)
Khi HIỂN THỊ booking (API → frontend):
// DB trả về UTC → convert sang salon local để hiển thị
import { formatTimeInZone } from '@/lib/timezone';
formatTimeInZone('2026-04-14T07:00:00Z', 'Europe/Oslo');
// → "09:00"
Khi tính AVAILABILITY (API):
// Schedule "09:00" là salon local → convert sang UTC để so sánh với bookings
import { localToUTC } from '../../common/timezone.util';
localToUTC('2026-04-14', '09:00', 'Europe/Oslo');
// → Date(2026-04-14T07:00:00Z)
KHÔNG BAO GIỜ làm
// ❌ KHÔNG: Coi schedule time là UTC
new Date(`${date}T${time}:00Z`) // Z = UTC! Schedule "09:00" ≠ 09:00 UTC
// ❌ KHÔNG: Dùng toISOString() để lấy date string
d.toISOString().split('T')[0] // Lệch ngày nếu local ≠ UTC
// ❌ KHÔNG: Dùng new Date(dateStr) không có time component
new Date('2026-04-14') // JS parse as UTC midnight → getDay() có thể lệch
// ❌ KHÔNG: Dùng getHours(), getDay() etc. trên UTC dates
booking.startTime.getHours() // Server timezone, không phải salon timezone
// ❌ KHÔNG: Dùng browser timezone cho bất cứ logic nào
new Date().getDay() // Browser timezone, không phải salon timezone
// (Trừ khi chỉ để hiển thị week navigation UI — không liên quan booking logic)
LUÔN LUÔN làm
// ✅ ĐÚNG: Date string từ Date object → dùng local methods
function toDateString(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
// ✅ ĐÚNG: DayOfWeek từ date string → dùng noon UTC (tránh lệch ngày)
function getDayOfWeek(date: string): number {
return new Date(`${date}T12:00:00Z`).getUTCDay();
}
// ✅ ĐÚNG: Hiển thị thời gian booking → dùng salon timezone
formatTimeInZone(booking.startTime, tenant.settings.timezone);
// ✅ ĐÚNG: So sánh business hours → convert UTC sang salon local
const parts = getPartsInZone(utcDate, salonTimezone);
// → { dayOfWeek, hour, minute } in salon local time
Utility Files
| File | Vị trí | Mục đích |
|---|---|---|
timezone.ts |
booking-web/src/lib/ |
Frontend: toUTC, toSalonTime, formatTimeInZone, getPartsInZone |
timezone.util.ts |
booking-api/src/common/ |
API: localToUTC, getDayOfWeek, getPartsInZone |
Flow Diagram
Owner set schedule "09:00-17:00"
→ Lưu DB: "09:00", "17:00" (plain string, salon local)
→ Hiển thị: "09:00-17:00" (as-is, no conversion)
Customer xem available slots (date: 2026-04-14)
→ API: localToUTC("2026-04-14", "09:00", "Europe/Oslo") = 07:00Z
→ API: localToUTC("2026-04-14", "17:00", "Europe/Oslo") = 15:00Z
→ Generate slots: 07:00Z, 07:15Z, 07:30Z...14:45Z
→ Response: slots as UTC ISO strings
→ Frontend: formatTimeInZone("07:00Z", "Europe/Oslo") = "09:00"
→ Customer thấy: 09:00, 09:15, 09:30...16:45
Customer book slot "09:00" (salon time)
→ Frontend: toUTC("2026-04-14", "09:00", "Europe/Oslo") = "2026-04-14T07:00:00Z"
→ API lưu DB: startTime = 2026-04-14T07:00:00Z
→ Calendar hiển thị: formatTimeInZone("07:00Z", "Europe/Oslo") = "09:00" ✓