rules/development-rules.md

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 @Interval khi 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ào feat/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 explicitrefunded ≤ 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 let nế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.lock changes không liên quan đến task

Banned patterns

  • moment.js → dùng date-fns hoặc native Date
  • lodash (full) → dùng native JS hoặc lodash/specific-function
  • axios → 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 .data sau ApiResponse
  • 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 lint passes (zero errors)
  • yarn build passes
  • yarn test passes
  • 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" ✓