690603:2041 ADR-034-134 #01
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# `.agents/skills/` — LCBP3 Agent Skill Pack
|
||||
|
||||
**Version:** 1.9.0 | **Last Updated:** 2026-05-17 | **Total Skills:** 23
|
||||
|
||||
Agent skills for AI-assisted development in **Windsurf IDE** (and compatible agents: Codex CLI, opencode, Amp, Antigravity, AGENTS.md-aware tools).
|
||||
|
||||
---
|
||||
|
||||
## 📂 Layout
|
||||
|
||||
```
|
||||
.agents/skills/
|
||||
├── VERSION # Single source of truth for skill-pack version
|
||||
├── skills.md # Overview + dependency matrix + health monitoring
|
||||
├── _LCBP3-CONTEXT.md # Shared LCBP3 context injected into every speckit-* skill
|
||||
├── README.md # (this file)
|
||||
├── nestjs-best-practices/ # Backend rules (40 rules across 10 categories)
|
||||
├── next-best-practices/ # Frontend rules (Next.js 15+)
|
||||
├── e2e-testing/ # Playwright E2E testing patterns (POM, flaky tests, CI/CD)
|
||||
├── verification-loop/ # Comprehensive verification (build, typecheck, lint, test, security)
|
||||
├── security-review/ # OWASP Top 10 + ADR compliance checklist
|
||||
└── speckit-*/ # 18 workflow skills (spec → plan → tasks → implement → …)
|
||||
```
|
||||
|
||||
Each skill directory contains:
|
||||
|
||||
- `SKILL.md` — frontmatter (`name`, `description`, `version: 1.9.0`, `scope`, `depends-on`, `handoffs`) + instructions
|
||||
- `templates/` _(optional)_ — artifact templates (spec/plan/tasks/checklist)
|
||||
- `rules/` _(nestjs only)_ — individual rule files grouped by prefix (`arch-`, `security-`, `db-`, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 How Windsurf Invokes These Skills
|
||||
|
||||
Windsurf exposes two entry points:
|
||||
|
||||
1. **Skill tool** — Windsurf discovers skills by scanning `.agents/skills/*/SKILL.md` frontmatter. Skills marked `user-invocable: false` are used silently by Cascade.
|
||||
2. **Slash commands** — `.windsurf/workflows/*.md` wraps each skill as a slash command (e.g. `/04-speckit.plan`). The workflow file is short; the heavy lifting is delegated to the skill via `skill` tool.
|
||||
|
||||
Both paths end up executing the same `SKILL.md` instructions.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Typical Flow
|
||||
|
||||
```
|
||||
/01-speckit.constitution → AGENTS.md / product vision
|
||||
/02-speckit.specify → specs/feat-XXX/spec.md
|
||||
/03-speckit.clarify → updates spec.md (up to 5 targeted questions)
|
||||
/04-speckit.plan → specs/feat-XXX/plan.md + data-model.md + contracts/
|
||||
/05-speckit.tasks → specs/feat-XXX/tasks.md
|
||||
/06-speckit.analyze → cross-artifact consistency report (read-only)
|
||||
/07-speckit.implement → executes tasks with Ironclad Protocols (Blast Radius + Strangler + TDD)
|
||||
/08-speckit.checker → pnpm lint / typecheck / markdown-lint
|
||||
/09-speckit.tester → pnpm test + coverage gates (Backend 70%+, Business Logic 80%+)
|
||||
/10-speckit.reviewer → code review with Tier 1/2/3 classification
|
||||
/11-speckit.validate → UAT / acceptance-criteria.md
|
||||
```
|
||||
|
||||
Use `/00-speckit.all` to run specify → clarify → plan → tasks → analyze in one go.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Helper Scripts
|
||||
|
||||
From repo root:
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `./.agents/scripts/bash/check-prerequisites.sh --json` | Emit `FEATURE_DIR` + `AVAILABLE_DOCS` for a feature branch |
|
||||
| `./.agents/scripts/bash/setup-plan.sh --json` | Emit `FEATURE_SPEC`, `IMPL_PLAN`, `SPECS_DIR`, `BRANCH` |
|
||||
| `./.agents/scripts/bash/update-agent-context.sh windsurf` | Append tech entries to `AGENTS.md` |
|
||||
| `./.agents/scripts/bash/audit-skills.sh` | Validate all `SKILL.md` frontmatter + presence |
|
||||
| `./.agents/scripts/bash/validate-versions.sh` | Version consistency check |
|
||||
| `./.agents/scripts/bash/sync-workflows.sh` | Verify every skill has a `.windsurf/workflows/*.md` wrapper |
|
||||
|
||||
All scripts mirror to `.agents/scripts/powershell/*.ps1` for Windows.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Tier 1 Non-Negotiables (auto-enforced)
|
||||
|
||||
- ADR-019 — `publicId` exposed directly; no `parseInt` / `Number` / `+` on UUID; no `id ?? ''` fallback
|
||||
- ADR-009 — edit SQL schema directly, no TypeORM migrations
|
||||
- ADR-016 — JWT + CASL on every mutation; `Idempotency-Key` required; ClamAV two-phase upload
|
||||
- ADR-018 — AI via DMS API only (Ollama on Admin Desktop; no direct DB/storage)
|
||||
- ADR-007 — layered error classification (Validation / Business / System)
|
||||
- Zero `any`, zero `console.log` (use `Logger`)
|
||||
|
||||
See [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md) for the complete list.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Extending
|
||||
|
||||
To add a new skill:
|
||||
|
||||
1. Create `NAME/SKILL.md` with frontmatter: `name`, `description`, `version: 1.9.0`, `scope`, `depends-on`.
|
||||
2. Append an LCBP3 context reference pointing to `_LCBP3-CONTEXT.md`.
|
||||
3. Wrap with `.windsurf/workflows/NAME.md` so it becomes a slash command.
|
||||
4. Update [`skills.md`](./skills.md) dependency matrix.
|
||||
5. Run `./.agents/scripts/bash/audit-skills.sh` → must pass.
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
- **Canonical rules:** `AGENTS.md` (repo root)
|
||||
- **Product vision:** `specs/00-Overview/00-03-product-vision.md`
|
||||
- **ADRs:** `specs/06-Decision-Records/`
|
||||
- **Engineering guidelines:** `specs/05-Engineering-Guidelines/`
|
||||
- **Contributing:** `CONTRIBUTING.md`
|
||||
@@ -0,0 +1 @@
|
||||
version: 1.9.0
|
||||
@@ -0,0 +1,98 @@
|
||||
# 🧭 LCBP3-DMS Context Appendix (Shared)
|
||||
|
||||
> This file is included/referenced by every Speckit skill as the authoritative project context.
|
||||
> Skills **must** load it (or the files it links to) before generating any artifact.
|
||||
|
||||
**Project:** NAP-DMS (LCBP3) — Laem Chabang Port Phase 3 Document Management System
|
||||
**Stack:** NestJS 11 + Next.js 16 + TypeScript + MariaDB 11.8 + Redis + BullMQ + Elasticsearch + Ollama (on-prem AI)
|
||||
**Version:** 1.9.7 (2026-05-25)
|
||||
|
||||
---
|
||||
|
||||
## 📌 Canonical Rule Sources (read in this order)
|
||||
|
||||
1. **`AGENTS.md`** (repo root) — primary rule file for AI agents; supersedes legacy `GEMINI.md`.
|
||||
2. **`specs/06-Decision-Records/`** — architectural decisions (29 ADRs); ADR priority > Engineering Guidelines.
|
||||
3. **`specs/05-Engineering-Guidelines/`** — backend/frontend/testing/i18n/git patterns.
|
||||
4. **`specs/00-Overview/00-02-glossary.md`** — domain terminology (Correspondence / RFA / Transmittal / Circulation).
|
||||
5. **`specs/00-Overview/00-03-product-vision.md`** — project constitution (Vision, Strategic Pillars, Guardrails).
|
||||
6. **`CONTRIBUTING.md`** — spec writing standards, PR template, review levels.
|
||||
7. **`README.md`** — technology stack + getting started.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Tier 1 Non-Negotiables
|
||||
|
||||
- **ADR-019 UUID:** `publicId: string` exposed directly — **no** `@Expose({ name: 'id' })` rename; **no** `parseInt`/`Number`/`+` on UUID; **no** `id ?? ''` fallback in frontend.
|
||||
- **ADR-009:** No TypeORM migrations — edit `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` or add a `deltas/*.sql` file.
|
||||
- **ADR-016 Security:** JWT + CASL 4-Level RBAC; `@UseGuards(JwtAuthGuard, CaslAbilityGuard)` on every mutation controller; `ThrottlerGuard` on auth; bcrypt 12 rounds; `Idempotency-Key` required on POST/PUT/PATCH.
|
||||
- **ADR-002 Document Numbering:** Redis Redlock + TypeORM `@VersionColumn` (double-lock). Never use application-side counter alone.
|
||||
- **ADR-008 Notifications:** BullMQ queue — never inline email/notification in a request thread.
|
||||
- **ADR-023/023A AI Boundary:** Ollama on Admin Desktop only; AI → DMS API → DB (never direct DB/storage). 2-model stack: `gemma4:e4b Q8_0` + `nomic-embed-text`. BullMQ `ai-realtime` / `ai-batch` queues. Human-in-the-loop validation required. (ADR-018 superseded by ADR-023)
|
||||
- **ADR-029 Dynamic Prompt Management:** Prompt templates in DB (`ai_prompts`), never hardcoded in processor; Redis cache `ai:prompt:active:{type}` TTL 60s; `activate()` runs in DB transaction + Redis DEL after commit; `system.manage_all` guard on all mutations.
|
||||
- **ADR-007 Error Handling:** Layered (Validation / Business / System); `BusinessException` hierarchy; user-friendly `userMessage` + `recoveryAction`; technical stack only in logs.
|
||||
- **TypeScript Strict:** Zero `any`, zero `console.log` (use NestJS `Logger`).
|
||||
- **i18n:** No hardcoded Thai/English strings in components — use i18n keys (see `05-08-i18n-guidelines.md`).
|
||||
- **File Upload:** Two-phase (Temp → ClamAV → Permanent), whitelist `PDF/DWG/DOCX/XLSX/ZIP`, max 50MB, `StorageService` only.
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Domain Glossary (reject generic terms)
|
||||
|
||||
| ✅ Use | ❌ Don't Use |
|
||||
| ------------------ | ------------------------------------- |
|
||||
| Correspondence | Letter, Communication, Document |
|
||||
| RFA | Approval Request, Submit for Approval |
|
||||
| Transmittal | Delivery Note, Cover Letter |
|
||||
| Circulation | Distribution, Routing |
|
||||
| Shop Drawing | Construction Drawing |
|
||||
| Contract Drawing | Design Drawing, Blueprint |
|
||||
| Workflow Engine | Approval Flow, Process Engine |
|
||||
| Document Numbering | Document ID, Auto Number |
|
||||
|
||||
---
|
||||
|
||||
## 📁 Key Files for Generating / Validating Artifacts
|
||||
|
||||
| When you need... | Read |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| A new feature spec | `.agents/skills/speckit-specify/templates/spec-template.md` + `specs/01-Requirements/01-06-edge-cases-and-rules.md` |
|
||||
| A plan | `.agents/skills/speckit-plan/templates/plan-template.md` + relevant ADRs |
|
||||
| Task breakdown | `.agents/skills/speckit-tasks/templates/tasks-template.md` + existing patterns in `specs/08-Tasks/` |
|
||||
| Acceptance criteria / UAT | `specs/01-Requirements/01-05-acceptance-criteria.md` |
|
||||
| Schema / table definition | `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql` + `03-01-data-dictionary.md` |
|
||||
| RBAC / permissions | `specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` + `01-02-01-rbac-matrix.md` |
|
||||
| Release / hotfix | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` |
|
||||
| ADR-024 Intent Class. | `specs/06-Decision-Records/ADR-024-intent-classification-strategy.md` |
|
||||
| ADR-025 AI Tool Layer | `specs/06-Decision-Records/ADR-025-ai-tool-layer-architecture.md` |
|
||||
| ADR-026 Chat UI | `specs/06-Decision-Records/ADR-026-document-chat-ui-pattern.md` |
|
||||
| ADR-027 AI Admin Console | `specs/06-Decision-Records/ADR-027-ai-admin-console-and-dynamic-control.md` |
|
||||
| ADR-028 Migration Refactor | `specs/06-Decision-Records/ADR-028-migration-architecture-refactor.md` |
|
||||
| ADR-029 Dynamic Prompts | `specs/06-Decision-Records/ADR-029-dynamic-prompt-management.md` |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Helper Scripts (real paths in this repo)
|
||||
|
||||
- `./.agents/scripts/bash/check-prerequisites.sh` / `powershell/*.ps1`
|
||||
- `./.agents/scripts/bash/setup-plan.sh`
|
||||
- `./.agents/scripts/bash/update-agent-context.sh windsurf`
|
||||
- `./.agents/scripts/bash/audit-skills.sh`
|
||||
- `./.agents/scripts/bash/validate-versions.sh`
|
||||
- `./.agents/scripts/bash/sync-workflows.sh`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Commit Checklist (applied automatically by speckit-implement)
|
||||
|
||||
- [ ] UUID pattern verified (no `parseInt` / `Number` / `+` on UUID, no `id ?? ''` fallback)
|
||||
- [ ] No `any`, no `console.log` in committed code
|
||||
- [ ] Business comments in Thai, code identifiers in English
|
||||
- [ ] Schema changes via SQL directly (not migration)
|
||||
- [ ] Test coverage meets targets (Backend 70%+, Business Logic 80%+)
|
||||
- [ ] Relevant ADRs referenced (007/008/009/016/019/021/023/023A/024-029 for AI work)
|
||||
- [ ] Domain glossary terms used correctly
|
||||
- [ ] Error handling: `Logger` + `HttpException` / `BusinessException`
|
||||
- [ ] i18n keys used (no hardcode text)
|
||||
- [ ] Cache invalidation when data mutated
|
||||
- [ ] OWASP Top 10 review passed
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: bugfix
|
||||
description: Quick bugfix workflow with minimal impact. Focused on surgical fixes without unrelated refactoring.
|
||||
version: 1.9.0
|
||||
---
|
||||
|
||||
# Bugfix
|
||||
|
||||
ใช้สำหรับแก้ไข Bug ที่ระบุสาเหตุได้ชัดเจน โดยเน้นที่การแก้ไขที่ตรงจุด (Surgical Fix) และมีผลกระทบน้อยที่สุด (Minimal Impact)
|
||||
|
||||
## Phase 1 — Analysis (การวิเคราะห์)
|
||||
|
||||
1. **Read Logs & Context**: อ่าน Error Logs หรือรายละเอียดที่ User แจ้งมาให้ครบถ้วน
|
||||
2. **Identify Root Cause**: ค้นหาสาเหตุที่แท้จริง (Root Cause) ไม่ใช่แค่การแก้ที่ปลายเหตุ
|
||||
3. **Check Error Catalog**: ตรวจสอบ `docs/error-catalog.md` เพื่อดูว่ามี Error Code หรือ Pattern ที่เกี่ยวข้องหรือไม่
|
||||
4. **Locate Code**: ระบุไฟล์และบรรทัดที่เกิดปัญหาให้ชัดเจน
|
||||
|
||||
## Phase 2 — Planning (การวางแผน)
|
||||
|
||||
1. **Create Fix Plan**: ร่างแผนการแก้ไขที่เน้น **Minimal Change**
|
||||
- ห้าม Refactor โค้ดที่ไม่เกี่ยวข้อง
|
||||
- หลีกเลี่ยงการเปลี่ยนแปลงที่จะส่งผลกระทบต่อส่วนอื่น (No Side Effects)
|
||||
2. **Verify Standards**: ตรวจสอบว่าแผนการแก้ไขไม่ขัดกับ Tier 1 (Security, UUID, DB) และ Tier 2 (Architecture) ใน `AGENTS.md`
|
||||
3. **Save to fix.md**: (Optional) บันทึกรายละเอียดการแก้ไขลงในไฟล์ `fix.md` เพื่อใช้ตรวจสอบก่อนลงมือจริง
|
||||
|
||||
## Phase 3 — Execution (การดำเนินการ)
|
||||
|
||||
1. **Apply Fix**: ลงมือแก้ไขโค้ดตามแผน
|
||||
2. **Verify Fix**:
|
||||
- จำลองสถานการณ์เพื่อยืนยันว่า Bug หายไปจริง
|
||||
- ตรวจสอบว่าไม่มี Forbidden Patterns (`any`, `console.log`, UUID misuse)
|
||||
3. **Regression Check**: ตรวจสอบส่วนที่เกี่ยวข้องว่ายังทำงานได้ปกติ
|
||||
|
||||
## Phase 4 — Finalization (การสรุปผล)
|
||||
|
||||
1. **Report**: สรุปผลการแก้ไขให้ User ทราบ
|
||||
- Root cause (สาเหตุ)
|
||||
- Fix detail (รายละเอียดการแก้)
|
||||
- Affected files (ไฟล์ที่เกี่ยวข้อง)
|
||||
2. **Cleanup**: ลบไฟล์ `fix.md` หรือ Debug logs ที่สร้างขึ้น
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: diagnose
|
||||
description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression.
|
||||
version: 1.9.0
|
||||
---
|
||||
|
||||
# Diagnose
|
||||
|
||||
A discipline for hard bugs. Skip phases only when explicitly justified.
|
||||
|
||||
When exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching.
|
||||
|
||||
## Phase 1 — Build a feedback loop
|
||||
|
||||
**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you.
|
||||
|
||||
Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.**
|
||||
|
||||
### Ways to construct one — try them in roughly this order
|
||||
|
||||
1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e.
|
||||
2. **Curl / HTTP script** against a running dev server.
|
||||
3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot.
|
||||
4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network.
|
||||
5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation.
|
||||
6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call.
|
||||
7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode.
|
||||
8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it.
|
||||
9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs.
|
||||
10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you.
|
||||
|
||||
Build the right feedback loop, and the bug is 90% fixed.
|
||||
|
||||
### Iterate on the loop itself
|
||||
|
||||
Treat the loop as a product. Once you have _a_ loop, ask:
|
||||
|
||||
- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.)
|
||||
- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".)
|
||||
- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.)
|
||||
|
||||
A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower.
|
||||
|
||||
### Non-deterministic bugs
|
||||
|
||||
The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable.
|
||||
|
||||
### When you genuinely cannot build a loop
|
||||
|
||||
Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop.
|
||||
|
||||
Do not proceed to Phase 2 until you have a loop you believe in.
|
||||
|
||||
## Phase 2 — Reproduce
|
||||
|
||||
Run the loop. Watch the bug appear.
|
||||
|
||||
Confirm:
|
||||
|
||||
- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix.
|
||||
- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against).
|
||||
- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it.
|
||||
|
||||
Do not proceed until you reproduce the bug.
|
||||
|
||||
## Phase 3 — Hypothesise
|
||||
|
||||
Generate **3–5 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea.
|
||||
|
||||
Each hypothesis must be **falsifiable**: state the prediction it makes.
|
||||
|
||||
> Format: "If <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> will make it worse."
|
||||
|
||||
If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it.
|
||||
|
||||
**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK.
|
||||
|
||||
## Phase 4 — Instrument
|
||||
|
||||
Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.**
|
||||
|
||||
Tool preference:
|
||||
|
||||
1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs.
|
||||
2. **Targeted logs** at the boundaries that distinguish hypotheses.
|
||||
3. Never "log everything and grep".
|
||||
|
||||
**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die.
|
||||
|
||||
**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second.
|
||||
|
||||
## Phase 5 — Fix + regression test
|
||||
|
||||
Write the regression test **before the fix** — but only if there is a **correct seam** for it.
|
||||
|
||||
A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence.
|
||||
|
||||
**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase.
|
||||
|
||||
If a correct seam exists:
|
||||
|
||||
1. Turn the minimised repro into a failing test at that seam.
|
||||
2. Watch it fail.
|
||||
3. Apply the fix.
|
||||
4. Watch it pass.
|
||||
5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario.
|
||||
|
||||
## Phase 6 — Cleanup + post-mortem
|
||||
|
||||
Required before declaring done:
|
||||
|
||||
- [ ] Original repro no longer reproduces (re-run the Phase 1 loop)
|
||||
- [ ] Regression test passes (or absence of seam is documented)
|
||||
- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix)
|
||||
- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location)
|
||||
- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns
|
||||
|
||||
**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started.
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Human-in-the-loop reproduction loop.
|
||||
# Copy this file, edit the steps below, and run it.
|
||||
# The agent runs the script; the user follows prompts in their terminal.
|
||||
#
|
||||
# Usage:
|
||||
# bash hitl-loop.template.sh
|
||||
#
|
||||
# Two helpers:
|
||||
# step "<instruction>" → show instruction, wait for Enter
|
||||
# capture VAR "<question>" → show question, read response into VAR
|
||||
#
|
||||
# At the end, captured values are printed as KEY=VALUE for the agent to parse.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
step() {
|
||||
printf '\n>>> %s\n' "$1"
|
||||
read -r -p " [Enter when done] " _
|
||||
}
|
||||
|
||||
capture() {
|
||||
local var="$1" question="$2" answer
|
||||
printf '\n>>> %s\n' "$question"
|
||||
read -r -p " > " answer
|
||||
printf -v "$var" '%s' "$answer"
|
||||
}
|
||||
|
||||
# --- edit below ---------------------------------------------------------
|
||||
|
||||
step "Open the app at http://localhost:3000 and sign in."
|
||||
|
||||
capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)"
|
||||
|
||||
capture ERROR_MSG "Paste the error message (or 'none'):"
|
||||
|
||||
# --- edit above ---------------------------------------------------------
|
||||
|
||||
printf '\n--- Captured ---\n'
|
||||
printf 'ERRORED=%s\n' "$ERRORED"
|
||||
printf 'ERROR_MSG=%s\n' "$ERROR_MSG"
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
name: e2e-testing
|
||||
description: Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies for LCBP3-DMS.
|
||||
version: 1.9.0
|
||||
scope: testing
|
||||
depends-on: []
|
||||
handoffs-to: [speckit-tester]
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# E2E Testing Skill
|
||||
|
||||
Playwright E2E testing patterns adapted for LCBP3-DMS (NestJS + Next.js + MariaDB stack).
|
||||
|
||||
## LCBP3 Context
|
||||
|
||||
See [`_LCBP3-CONTEXT.md`](../_LCBP3-CONTEXT.md) for project-specific testing requirements:
|
||||
- Backend: Jest (Unit + Integration + E2E)
|
||||
- Frontend: Vitest (Unit) + Playwright (E2E)
|
||||
- E2E test location: `frontend/e2e/workflow-adr021.spec.ts`
|
||||
- Coverage goals: Backend 70%+, Business Logic 80%+
|
||||
|
||||
## When to Use
|
||||
|
||||
Invoke this skill when:
|
||||
- Creating new E2E tests for frontend features
|
||||
- Debugging flaky Playwright tests
|
||||
- Setting up CI/CD integration for E2E tests
|
||||
- Optimizing test performance and reliability
|
||||
- Implementing Page Object Model (POM) patterns
|
||||
|
||||
## Test File Organization
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── e2e/
|
||||
│ ├── auth/
|
||||
│ │ ├── login.spec.ts
|
||||
│ │ └── logout.spec.ts
|
||||
│ ├── correspondence/
|
||||
│ │ ├── create.spec.ts
|
||||
│ │ └── workflow.spec.ts
|
||||
│ ├── transmittals/
|
||||
│ │ ├── create.spec.ts
|
||||
│ │ └── submit.spec.ts
|
||||
│ ├── circulation/
|
||||
│ │ ├── routing.spec.ts
|
||||
│ │ └── approval.spec.ts
|
||||
│ └── workflow-adr021.spec.ts # Existing ADR-021 integration test
|
||||
├── playwright.config.ts
|
||||
└── tests/
|
||||
└── fixtures/
|
||||
├── auth.ts
|
||||
└── data.ts
|
||||
```
|
||||
|
||||
## Page Object Model (POM)
|
||||
|
||||
```typescript
|
||||
// frontend/e2e/pages/CorrespondencePage.ts
|
||||
import { Page, Locator } from '@playwright/test'
|
||||
|
||||
export class CorrespondencePage {
|
||||
readonly page: Page
|
||||
readonly createButton: Locator
|
||||
readonly subjectInput: Locator
|
||||
readonly recipientSelect: Locator
|
||||
readonly submitButton: Locator
|
||||
readonly successMessage: Locator
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page
|
||||
this.createButton = page.getByTestId('create-correspondence')
|
||||
this.subjectInput = page.getByTestId('subject-input')
|
||||
this.recipientSelect = page.getByTestId('recipient-select')
|
||||
this.submitButton = page.getByTestId('submit-button')
|
||||
this.successMessage = page.getByTestId('success-message')
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/admin/doc-control/correspondences')
|
||||
await this.page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
async createCorrespondence(data: {
|
||||
subject: string
|
||||
recipientId: string
|
||||
}) {
|
||||
await this.createButton.click()
|
||||
await this.subjectInput.fill(data.subject)
|
||||
await this.recipientSelect.selectOption(data.recipientId)
|
||||
await this.submitButton.click()
|
||||
}
|
||||
|
||||
async verifySuccess() {
|
||||
await expect(this.successMessage).toBeVisible()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```typescript
|
||||
// frontend/e2e/correspondence/create.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { CorrespondencePage } from '../pages/CorrespondencePage'
|
||||
|
||||
test.describe('Correspondence Creation', () => {
|
||||
let correspondencePage: CorrespondencePage
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
correspondencePage = new CorrespondencePage(page)
|
||||
await correspondencePage.goto()
|
||||
})
|
||||
|
||||
test('should create correspondence successfully', async ({ page }) => {
|
||||
await correspondencePage.createCorrespondence({
|
||||
subject: 'Test Correspondence',
|
||||
recipientId: 'test-recipient-id'
|
||||
})
|
||||
|
||||
await correspondencePage.verifySuccess()
|
||||
await page.screenshot({ path: 'artifacts/correspondence-created.png' })
|
||||
})
|
||||
|
||||
test('should validate required fields', async ({ page }) => {
|
||||
await correspondencePage.createButton.click()
|
||||
await correspondencePage.submitButton.click()
|
||||
|
||||
await expect(page.getByTestId('subject-error')).toBeVisible()
|
||||
await expect(page.getByTestId('recipient-error')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Playwright Configuration
|
||||
|
||||
```typescript
|
||||
// frontend/playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: [
|
||||
['html', { outputFolder: 'playwright-report' }],
|
||||
['junit', { outputFile: 'playwright-results.xml' }],
|
||||
['json', { outputFile: 'playwright-results.json' }]
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Flaky Test Patterns
|
||||
|
||||
### Quarantine
|
||||
|
||||
```typescript
|
||||
test('flaky: complex workflow', async ({ page }) => {
|
||||
test.fixme(true, 'Flaky - Issue #123')
|
||||
// test code...
|
||||
})
|
||||
|
||||
test('conditional skip', async ({ page }) => {
|
||||
test.skip(process.env.CI, 'Flaky in CI - Issue #123')
|
||||
// test code...
|
||||
})
|
||||
```
|
||||
|
||||
### Identify Flakiness
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npx playwright test e2e/correspondence/create.spec.ts --repeat-each=10
|
||||
npx playwright test e2e/correspondence/create.spec.ts --retries=3
|
||||
```
|
||||
|
||||
### Common Causes & Fixes
|
||||
|
||||
**Race conditions:**
|
||||
```typescript
|
||||
// Bad: assumes element is ready
|
||||
await page.click('[data-testid="submit-button"]')
|
||||
|
||||
// Good: auto-wait locator
|
||||
await page.locator('[data-testid="submit-button"]').click()
|
||||
```
|
||||
|
||||
**Network timing:**
|
||||
```typescript
|
||||
// Bad: arbitrary timeout
|
||||
await page.waitForTimeout(5000)
|
||||
|
||||
// Good: wait for specific condition
|
||||
await page.waitForResponse(resp =>
|
||||
resp.url().includes('/api/correspondences') && resp.status() === 201
|
||||
)
|
||||
```
|
||||
|
||||
**Animation timing:**
|
||||
```typescript
|
||||
// Bad: click during animation
|
||||
await page.click('[data-testid="menu-item"]')
|
||||
|
||||
// Good: wait for stability
|
||||
await page.locator('[data-testid="menu-item"]').waitFor({ state: 'visible' })
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.locator('[data-testid="menu-item"]').click()
|
||||
```
|
||||
|
||||
## Artifact Management
|
||||
|
||||
### Screenshots
|
||||
|
||||
```typescript
|
||||
await page.screenshot({ path: 'artifacts/after-login.png' })
|
||||
await page.screenshot({ path: 'artifacts/full-page.png', fullPage: true })
|
||||
await page.locator('[data-testid="workflow-banner"]').screenshot({
|
||||
path: 'artifacts/workflow-banner.png'
|
||||
})
|
||||
```
|
||||
|
||||
### Traces
|
||||
|
||||
```typescript
|
||||
// In playwright.config.ts
|
||||
use: {
|
||||
trace: 'on-first-retry'
|
||||
}
|
||||
|
||||
// View trace
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
### Video
|
||||
|
||||
```typescript
|
||||
// In playwright.config.ts
|
||||
use: {
|
||||
video: 'retain-on-failure',
|
||||
videosPath: 'artifacts/videos/'
|
||||
}
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
```yaml
|
||||
# .github/workflows/e2e.yml
|
||||
name: E2E Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: pnpm install
|
||||
- run: cd frontend && npx playwright install --with-deps
|
||||
- run: cd frontend && npx playwright test
|
||||
env:
|
||||
BASE_URL: ${{ vars.STAGING_URL }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
## Test Report Template
|
||||
|
||||
```markdown
|
||||
# E2E Test Report
|
||||
|
||||
**Date:** YYYY-MM-DD HH:MM
|
||||
**Duration:** Xm Ys
|
||||
**Status:** PASSING / FAILING
|
||||
|
||||
## Summary
|
||||
- Total: X | Passed: Y (Z%) | Failed: A | Flaky: B | Skipped: C
|
||||
|
||||
## Failed Tests
|
||||
|
||||
### correspondence-create
|
||||
**File:** `frontend/e2e/correspondence/create.spec.ts:45`
|
||||
**Error:** Expected element to be visible
|
||||
**Screenshot:** artifacts/failed.png
|
||||
**Recommended Fix:** Add waitForLoadState after form submission
|
||||
|
||||
## Artifacts
|
||||
- HTML Report: frontend/playwright-report/index.html
|
||||
- Screenshots: frontend/artifacts/*.png
|
||||
- Videos: frontend/artifacts/videos/*.webm
|
||||
- Traces: frontend/artifacts/*.zip
|
||||
```
|
||||
|
||||
## Critical Flow Testing
|
||||
|
||||
```typescript
|
||||
// frontend/e2e/workflow/adr021.spec.ts
|
||||
test('workflow: correspondence → rfa → approval', async ({ page }) => {
|
||||
// Create correspondence
|
||||
await createCorrespondence(page)
|
||||
await expect(page.getByTestId('correspondence-created')).toBeVisible()
|
||||
|
||||
// Submit for RFA
|
||||
await page.getByTestId('submit-rfa').click()
|
||||
await expect(page.getByTestId('rfa-submitted')).toBeVisible()
|
||||
|
||||
// Approve RFA
|
||||
await page.goto('/admin/doc-control/rfa/123')
|
||||
await page.getByTestId('approve-button').click()
|
||||
await expect(page.getByTestId('approval-success')).toBeVisible()
|
||||
|
||||
// Verify workflow state
|
||||
await expect(page.getByTestId('workflow-state')).toContainText('APPROVED')
|
||||
})
|
||||
```
|
||||
|
||||
## LCBP3-Specific Considerations
|
||||
|
||||
- **UUID Handling:** Use `publicId` (string UUID) in E2E tests, never `parseInt()` (ADR-019)
|
||||
- **Authentication:** Mock auth tokens for E2E tests to avoid real auth flows
|
||||
- **Workflow States:** Test ADR-021 workflow transitions (DRAFT → PENDING → APPROVED)
|
||||
- **i18n:** Test with Thai language to verify i18n key resolution
|
||||
- **RBAC:** Test different user roles (admin, user, reviewer) for permission checks
|
||||
|
||||
## References
|
||||
|
||||
- LCBP3 Testing Strategy: `specs/05-Engineering-Guidelines/05-04-testing-strategy.md`
|
||||
- ADR-021 Workflow Context: `specs/06-Decision-Records/ADR-021-workflow-context.md`
|
||||
- Existing E2E test: `frontend/e2e/workflow-adr021.spec.ts`
|
||||
@@ -0,0 +1,47 @@
|
||||
# ADR Format
|
||||
|
||||
ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
||||
|
||||
Create the `docs/adr/` directory lazily — only when the first ADR is needed.
|
||||
|
||||
## Template
|
||||
|
||||
```md
|
||||
# {Short title of the decision}
|
||||
|
||||
{1-3 sentences: what's the context, what did we decide, and why.}
|
||||
```
|
||||
|
||||
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
||||
|
||||
## Optional sections
|
||||
|
||||
Only include these when they add genuine value. Most ADRs won't need them.
|
||||
|
||||
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
||||
- **Considered Options** — only when the rejected alternatives are worth remembering
|
||||
- **Consequences** — only when non-obvious downstream effects need to be called out
|
||||
|
||||
## Numbering
|
||||
|
||||
Scan `docs/adr/` for the highest existing number and increment by one.
|
||||
|
||||
## When to offer an ADR
|
||||
|
||||
All three of these must be true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
||||
|
||||
### What qualifies
|
||||
|
||||
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
||||
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
||||
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
||||
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
||||
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
||||
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
||||
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
||||
@@ -0,0 +1,77 @@
|
||||
# CONTEXT.md Format
|
||||
|
||||
## Structure
|
||||
|
||||
```md
|
||||
# {Context Name}
|
||||
|
||||
{One or two sentence description of what this context is and why it exists.}
|
||||
|
||||
## Language
|
||||
|
||||
**Order**:
|
||||
{A concise description of the term}
|
||||
_Avoid_: Purchase, transaction
|
||||
|
||||
**Invoice**:
|
||||
A request for payment sent to a customer after delivery.
|
||||
_Avoid_: Bill, payment request
|
||||
|
||||
**Customer**:
|
||||
A person or organization that places orders.
|
||||
_Avoid_: Client, buyer, account
|
||||
|
||||
## Relationships
|
||||
|
||||
- An **Order** produces one or more **Invoices**
|
||||
- An **Invoice** belongs to exactly one **Customer**
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "When a **Customer** places an **Order**, do we create the **Invoice** immediately?"
|
||||
> **Domain expert:** "No — an **Invoice** is only generated once a **Fulfillment** is confirmed."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "account" was used to mean both **Customer** and **User** — resolved: these are distinct concepts.
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others as aliases to avoid.
|
||||
- **Flag conflicts explicitly.** If a term is used ambiguously, call it out in "Flagged ambiguities" with a clear resolution.
|
||||
- **Keep definitions tight.** One sentence max. Define what it IS, not what it does.
|
||||
- **Show relationships.** Use bold term names and express cardinality where obvious.
|
||||
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
||||
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
||||
- **Write an example dialogue.** A conversation between a dev and a domain expert that demonstrates how the terms interact naturally and clarifies boundaries between related concepts.
|
||||
|
||||
## Single vs multi-context repos
|
||||
|
||||
**Single context (most repos):** One `CONTEXT.md` at the repo root.
|
||||
|
||||
**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
|
||||
|
||||
```md
|
||||
# Context Map
|
||||
|
||||
## Contexts
|
||||
|
||||
- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
|
||||
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
|
||||
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
|
||||
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
|
||||
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
|
||||
```
|
||||
|
||||
The skill infers which structure applies:
|
||||
|
||||
- If `CONTEXT-MAP.md` exists, read it to find contexts
|
||||
- If only a root `CONTEXT.md` exists, single context
|
||||
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
|
||||
|
||||
When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: grill-with-docs
|
||||
description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions.
|
||||
version: 1.9.0
|
||||
---
|
||||
|
||||
<what-to-do>
|
||||
|
||||
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions one at a time, waiting for feedback on each question before continuing.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead.
|
||||
|
||||
</what-to-do>
|
||||
|
||||
<supporting-info>
|
||||
|
||||
## Domain awareness
|
||||
|
||||
During codebase exploration, also look for existing documentation:
|
||||
|
||||
### File structure
|
||||
|
||||
Most repos have a single context:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/
|
||||
│ └── adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/
|
||||
│ └── adr/ ← system-wide decisions
|
||||
├── src/
|
||||
│ ├── ordering/
|
||||
│ │ ├── CONTEXT.md
|
||||
│ │ └── docs/adr/ ← context-specific decisions
|
||||
│ └── billing/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/
|
||||
```
|
||||
|
||||
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.
|
||||
|
||||
## During the session
|
||||
|
||||
### Challenge against the glossary
|
||||
|
||||
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
||||
|
||||
### Sharpen fuzzy language
|
||||
|
||||
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
||||
|
||||
### Discuss concrete scenarios
|
||||
|
||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
||||
|
||||
### Cross-reference with code
|
||||
|
||||
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
||||
|
||||
### Update CONTEXT.md inline
|
||||
|
||||
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
||||
|
||||
Don't couple `CONTEXT.md` to implementation details. Only include terms that are meaningful to domain experts.
|
||||
|
||||
### Offer ADRs sparingly
|
||||
|
||||
Only offer to create an ADR when all three are true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
||||
|
||||
</supporting-info>
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Branch Protection
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
check-branch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Block docs branch merge to main
|
||||
if: github.head_ref == 'docs'
|
||||
run: |
|
||||
echo "::error::Merging 'docs' branch into 'main' is not allowed."
|
||||
echo ""
|
||||
echo "The 'docs' branch contains the documentation website which should"
|
||||
echo "remain separate from the main skill files to keep installations lightweight."
|
||||
echo ""
|
||||
echo "If you need to sync changes, cherry-pick specific commits instead."
|
||||
exit 1
|
||||
|
||||
- name: Branch check passed
|
||||
if: github.head_ref != 'docs'
|
||||
run: echo "Branch check passed - not merging from docs branch"
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [docs]
|
||||
paths:
|
||||
- 'website/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: website
|
||||
|
||||
- name: Copy index.html to 404.html for SPA routing
|
||||
run: cp website/dist/index.html website/dist/404.html
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,29 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Website (lives on docs branch)
|
||||
website/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*.js
|
||||
*.d.ts
|
||||
*.js.map
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
# NestJS Best Practices
|
||||
|
||||
📖 [For Humans <3](https://kadajett.github.io/agent-nestjs-skills/)
|
||||
|
||||
A structured repository for creating and maintaining NestJS Best Practices optimized for agents and LLMs.
|
||||
|
||||
## Installation
|
||||
|
||||
Install this skill using [skills](https://github.com/vercel-labs/skills):
|
||||
|
||||
```bash
|
||||
# GitHub shorthand
|
||||
npx skills add Kadajett/agent-nestjs-skills
|
||||
|
||||
# Install globally (available across all projects)
|
||||
npx skills add Kadajett/agent-nestjs-skills --global
|
||||
|
||||
# Install for specific agents
|
||||
npx skills add Kadajett/agent-nestjs-skills -a claude-code -a cursor
|
||||
```
|
||||
|
||||
### Supported Agents
|
||||
|
||||
- Claude Code
|
||||
- OpenCode
|
||||
- Codex
|
||||
- Cursor
|
||||
- Antigravity
|
||||
- Roo Code
|
||||
|
||||
## Structure
|
||||
|
||||
- `rules/` - Individual rule files (one per rule)
|
||||
- `_sections.md` - Section metadata (titles, impacts, descriptions)
|
||||
- `_template.md` - Template for creating new rules
|
||||
- `area-description.md` - Individual rule files
|
||||
- `scripts/` - Build scripts and utilities
|
||||
- `metadata.json` - Document metadata (version, organization, abstract)
|
||||
- **`AGENTS.md`** - Compiled output (generated)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
cd scripts && npm install
|
||||
```
|
||||
|
||||
2. Build AGENTS.md from rules:
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
## Creating a New Rule
|
||||
|
||||
1. Copy `rules/_template.md` to `rules/area-description.md`
|
||||
2. Choose the appropriate area prefix:
|
||||
- `arch-` for Architecture (Section 1)
|
||||
- `di-` for Dependency Injection (Section 2)
|
||||
- `error-` for Error Handling (Section 3)
|
||||
- `security-` for Security (Section 4)
|
||||
- `perf-` for Performance (Section 5)
|
||||
- `test-` for Testing (Section 6)
|
||||
- `db-` for Database & ORM (Section 7)
|
||||
- `api-` for API Design (Section 8)
|
||||
- `micro-` for Microservices (Section 9)
|
||||
- `devops-` for DevOps & Deployment (Section 10)
|
||||
3. Fill in the frontmatter and content
|
||||
4. Ensure you have clear examples with explanations
|
||||
5. Run the build script to regenerate AGENTS.md
|
||||
|
||||
## Rule File Structure
|
||||
|
||||
Each rule file should follow this structure:
|
||||
|
||||
````markdown
|
||||
---
|
||||
title: Rule Title Here
|
||||
impact: MEDIUM
|
||||
impactDescription: Optional description
|
||||
tags: tag1, tag2, tag3
|
||||
---
|
||||
|
||||
## Rule Title Here
|
||||
|
||||
Brief explanation of the rule and why it matters.
|
||||
|
||||
**Incorrect (description of what's wrong):**
|
||||
|
||||
```typescript
|
||||
// Bad code example
|
||||
```
|
||||
````
|
||||
|
||||
**Correct (description of what's right):**
|
||||
|
||||
```typescript
|
||||
// Good code example
|
||||
```
|
||||
|
||||
Optional explanatory text after examples.
|
||||
|
||||
Reference: [NestJS Documentation](https://docs.nestjs.com)
|
||||
|
||||
## File Naming Convention
|
||||
|
||||
- Files starting with `_` are special (excluded from build)
|
||||
- Rule files: `area-description.md` (e.g., `arch-avoid-circular-deps.md`)
|
||||
- Section is automatically inferred from filename prefix
|
||||
- Rules are sorted alphabetically by title within each section
|
||||
- IDs (e.g., 1.1, 1.2) are auto-generated during build
|
||||
|
||||
## Impact Levels
|
||||
|
||||
| Level | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------- |
|
||||
| CRITICAL | Violations cause runtime errors, security vulnerabilities, or architectural breakdown |
|
||||
| HIGH | Significant impact on reliability, security, or maintainability |
|
||||
| MEDIUM-HIGH | Notable impact on quality and developer experience |
|
||||
| MEDIUM | Moderate impact on code quality and best practices |
|
||||
| LOW-MEDIUM | Minor improvements for consistency and maintainability |
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run build` (in scripts/) - Compile rules into AGENTS.md
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding or modifying rules:
|
||||
|
||||
1. Use the correct filename prefix for your section
|
||||
2. Follow the `_template.md` structure
|
||||
3. Include clear bad/good examples with explanations
|
||||
4. Add appropriate tags
|
||||
5. Run the build script to regenerate AGENTS.md
|
||||
6. Rules are automatically sorted by title - no need to manage numbers!
|
||||
|
||||
## Documentation Website
|
||||
|
||||
The documentation website source code lives on the [`docs` branch](https://github.com/Kadajett/agent-nestjs-skills/tree/docs/website). This separation keeps the skill installation lightweight while maintaining the full documentation site.
|
||||
|
||||
To contribute to the website:
|
||||
|
||||
```bash
|
||||
git checkout docs
|
||||
cd website
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Inspired by the [Vercel React Best Practices](https://github.com/vercel-labs/agent-skills) skill structure
|
||||
- Compatible with [skills](https://github.com/vercel-labs/skills) for easy installation across coding agents
|
||||
|
||||
## Compatible Agents
|
||||
|
||||
These NestJS skills work with:
|
||||
|
||||
- [Claude Code](https://claude.ai/code) - Anthropic's official CLI
|
||||
- [AdaL](https://sylph.ai/adal) - Self-evolving AI coding agent with MCP support
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
name: nestjs-best-practices
|
||||
description: NestJS best practices and architecture patterns for building production-ready LCBP3-DMS backend code. Enforces ADR-009 (no TypeORM migrations), ADR-019 (hybrid UUID), ADR-016 (security), ADR-007 (error handling), ADR-008 (BullMQ), ADR-001/002 (workflow + numbering), ADR-018/020 (AI boundary), and ADR-021 (workflow context).
|
||||
version: 1.9.0
|
||||
scope: backend
|
||||
user-invocable: false
|
||||
license: MIT
|
||||
metadata:
|
||||
upstream: 'Kadajett/nestjs-best-practices v1.1.0 (forked + LCBP3-aligned)'
|
||||
---
|
||||
|
||||
# NestJS Best Practices
|
||||
|
||||
Comprehensive best practices guide for NestJS applications. Contains 40 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
|
||||
- Writing new NestJS modules, controllers, or services
|
||||
- Implementing authentication and authorization
|
||||
- Reviewing code for architecture and security issues
|
||||
- Refactoring existing NestJS codebases
|
||||
- Optimizing performance or database queries
|
||||
- Building microservices architectures
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
| -------- | -------------------- | ----------- | ----------- |
|
||||
| 1 | Architecture | CRITICAL | `arch-` |
|
||||
| 2 | Dependency Injection | CRITICAL | `di-` |
|
||||
| 3 | Error Handling | HIGH | `error-` |
|
||||
| 4 | Security | HIGH | `security-` |
|
||||
| 5 | Performance | HIGH | `perf-` |
|
||||
| 6 | Testing | MEDIUM-HIGH | `test-` |
|
||||
| 7 | Database & ORM | MEDIUM-HIGH | `db-` |
|
||||
| 8 | API Design | MEDIUM | `api-` |
|
||||
| 9 | Microservices | MEDIUM | `micro-` |
|
||||
| 10 | DevOps & Deployment | LOW-MEDIUM | `devops-` |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### 1. Architecture (CRITICAL)
|
||||
|
||||
- `arch-avoid-circular-deps` - Avoid circular module dependencies
|
||||
- `arch-feature-modules` - Organize by feature, not technical layer
|
||||
- `arch-module-sharing` - Proper module exports/imports, avoid duplicate providers
|
||||
- `arch-single-responsibility` - Focused services over "god services"
|
||||
- `arch-use-repository-pattern` - Abstract database logic for testability
|
||||
- `arch-use-events` - Event-driven architecture for decoupling
|
||||
|
||||
### 2. Dependency Injection (CRITICAL)
|
||||
|
||||
- `di-avoid-service-locator` - Avoid service locator anti-pattern
|
||||
- `di-interface-segregation` - Interface Segregation Principle (ISP)
|
||||
- `di-liskov-substitution` - Liskov Substitution Principle (LSP)
|
||||
- `di-prefer-constructor-injection` - Constructor over property injection
|
||||
- `di-scope-awareness` - Understand singleton/request/transient scopes
|
||||
- `di-use-interfaces-tokens` - Use injection tokens for interfaces
|
||||
|
||||
### 3. Error Handling (HIGH)
|
||||
|
||||
- `error-use-exception-filters` - Centralized exception handling
|
||||
- `error-throw-http-exceptions` - Use NestJS HTTP exceptions
|
||||
- `error-handle-async-errors` - Handle async errors properly
|
||||
|
||||
### 4. Security (HIGH)
|
||||
|
||||
- `security-auth-jwt` - Secure JWT authentication
|
||||
- `security-validate-all-input` - Validate with class-validator
|
||||
- `security-use-guards` - Authentication and authorization guards
|
||||
- `security-sanitize-output` - Prevent XSS attacks
|
||||
- `security-rate-limiting` - Implement rate limiting
|
||||
|
||||
### 5. Performance (HIGH)
|
||||
|
||||
- `perf-async-hooks` - Proper async lifecycle hooks
|
||||
- `perf-use-caching` - Implement caching strategies
|
||||
- `perf-optimize-database` - Optimize database queries
|
||||
- `perf-lazy-loading` - Lazy load modules for faster startup
|
||||
|
||||
### 6. Testing (MEDIUM-HIGH)
|
||||
|
||||
- `test-use-testing-module` - Use NestJS testing utilities
|
||||
- `test-e2e-supertest` - E2E testing with Supertest
|
||||
- `test-mock-external-services` - Mock external dependencies
|
||||
|
||||
### 7. Database & ORM (MEDIUM-HIGH)
|
||||
|
||||
- `db-hybrid-identifier` - **CRITICAL** ADR-019: INT PK + UUID public API
|
||||
- `db-avoid-n-plus-one` - HIGH N+1 query prevention
|
||||
- `db-use-transactions` - HIGH Transaction management
|
||||
- `db-no-typeorm-migrations` - **CRITICAL** ADR-009: No TypeORM migrations - use SQL files
|
||||
|
||||
### 8. API Design (MEDIUM)
|
||||
|
||||
- `api-use-dto-serialization` - DTO and response serialization
|
||||
- `api-use-interceptors` - Cross-cutting concerns
|
||||
- `api-versioning` - API versioning strategies
|
||||
- `api-use-pipes` - Input transformation with pipes
|
||||
|
||||
### 9. Microservices (MEDIUM)
|
||||
|
||||
- `micro-use-patterns` - Message and event patterns
|
||||
- `micro-use-health-checks` - Health checks for orchestration
|
||||
- `micro-use-queues` - Background job processing
|
||||
|
||||
### 10. DevOps & Deployment (LOW-MEDIUM)
|
||||
|
||||
- `devops-use-config-module` - Environment configuration
|
||||
- `devops-use-logging` - Structured logging
|
||||
- `devops-graceful-shutdown` - Zero-downtime deployments
|
||||
|
||||
### 11. LCBP3-Specific (CRITICAL — Project Overrides)
|
||||
|
||||
- `db-no-typeorm-migrations` — **CRITICAL** ADR-009: edit SQL directly
|
||||
- `lcbp3-workflow-engine` — **CRITICAL** ADR-001/002/021: DSL state machine + double-lock numbering + workflow context
|
||||
- `security-file-two-phase-upload` — **CRITICAL** ADR-016: Upload → Temp → ClamAV → Commit
|
||||
- `lcbp3-ai-boundary` — **CRITICAL** ADR-018/020: Ollama on-prem only, human-in-the-loop
|
||||
|
||||
## NAP-DMS Project-Specific Rules (MUST FOLLOW)
|
||||
|
||||
These rules override general NestJS best practices for the NAP-DMS project:
|
||||
|
||||
### ADR-009: No TypeORM Migrations
|
||||
|
||||
- **ห้ามสร้างไฟล์ migration ของ TypeORM**
|
||||
- แก้ไข schema โดยตรงที่: `specs/03-Data-and-Storage/lcbp3-v1.9.0-schema-02-tables.sql`
|
||||
- ใช้ n8n workflow สำหรับ data migration ถ้าจำเป็น
|
||||
|
||||
### ADR-019: Hybrid Identifier Strategy (CRITICAL — March 2026 Pattern)
|
||||
|
||||
> **Updated pattern:** `UuidBaseEntity` exposes `publicId` **directly**. ห้ามใช้ `@Expose({ name: 'id' })` — API จะคืน `publicId` เป็น field name ตรงๆ.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT — ใช้ UuidBaseEntity
|
||||
@Entity()
|
||||
export class Project extends UuidBaseEntity {
|
||||
// publicId (string UUIDv7) + id (INT, @Exclude) สืบทอดจาก UuidBaseEntity
|
||||
// API response → { publicId: "019505a1-7c3e-7000-8000-abc123..." }
|
||||
|
||||
@Column()
|
||||
projectCode: string;
|
||||
|
||||
@Column()
|
||||
projectName: string;
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG — pattern เก่า ห้ามใช้
|
||||
@Entity()
|
||||
export class OldProject {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude()
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
@Expose({ name: 'id' }) // ❌ อย่า rename publicId เป็น 'id'
|
||||
publicId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**DTO Input (รับ UUID จาก Frontend):**
|
||||
|
||||
```typescript
|
||||
export class CreateContractDto {
|
||||
@IsUUID('7')
|
||||
projectUuid: string; // รับ UUID string จาก client
|
||||
}
|
||||
|
||||
// Controller resolves UUID → INT internally
|
||||
@Post()
|
||||
async create(@Body() dto: CreateContractDto) {
|
||||
const projectId = await this.projectService.resolveInternalId(dto.projectUuid);
|
||||
return this.contractService.create({ ...dto, projectId });
|
||||
}
|
||||
```
|
||||
|
||||
**ห้ามเด็ดขาด (CI Blocker):**
|
||||
|
||||
- ❌ `parseInt(projectPublicId)` — "019505…" → 19 (silently wrong)
|
||||
- ❌ `Number(publicId)` / `+publicId` — NaN
|
||||
- ❌ `@Expose({ name: 'id' })` บน `publicId` (pattern เก่า)
|
||||
- ❌ Expose INT `id` ใน API response (ต้อง `@Exclude()` เสมอ)
|
||||
|
||||
### Two-Phase File Upload
|
||||
|
||||
```typescript
|
||||
// Phase 1: Upload to temp
|
||||
@Post('upload')
|
||||
async uploadFile(@UploadedFile() file: Express.Multer.File) {
|
||||
await this.virusScan(file);
|
||||
const tempId = await this.fileStorage.saveToTemp(file);
|
||||
return { temp_id: tempId, expires_at: addHours(new Date(), 24) };
|
||||
}
|
||||
|
||||
// Phase 2: Commit in transaction
|
||||
async createEntity(dto: CreateDto, tempIds: string[]) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const entity = await manager.save(Entity, dto);
|
||||
await this.fileStorage.commitFiles(tempIds, entity.id, manager);
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Idempotency Requirement
|
||||
|
||||
- ทุก POST/PUT/PATCH ที่สำคัญต้องมี `Idempotency-Key` header
|
||||
- ใช้ `IdempotencyInterceptor` ที่มีอยู่แล้ว
|
||||
|
||||
### Document Numbering (Double-Lock)
|
||||
|
||||
```typescript
|
||||
async generateNextNumber(context: NumberingContext): Promise<string> {
|
||||
const lockKey = `doc_num:${context.projectId}:${context.typeId}`;
|
||||
const lock = await this.redisLock.acquire(lockKey, 3000);
|
||||
|
||||
try {
|
||||
const counter = await this.counterRepo.findOne({
|
||||
where: context,
|
||||
lock: { mode: 'optimistic' },
|
||||
});
|
||||
counter.last_number++;
|
||||
return this.formatNumber(await this.counterRepo.save(counter));
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns (ห้ามทำ)
|
||||
|
||||
- ❌ ใช้ SQL Triggers สำหรับ business logic
|
||||
- ❌ ใช้ `.env` ใน production (ใช้ Docker ENV)
|
||||
- ❌ ใช้ `any` type (strict mode enforced)
|
||||
- ❌ ใช้ `console.log` (ใช้ NestJS Logger)
|
||||
- ❌ สร้างตาราง routing แยก (ใช้ Workflow Engine)
|
||||
|
||||
---
|
||||
|
||||
Read individual rule files for detailed explanations and code examples:
|
||||
|
||||
```
|
||||
rules/arch-avoid-circular-deps.md
|
||||
rules/security-validate-all-input.md
|
||||
rules/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect code example with explanation
|
||||
- Correct code example with explanation
|
||||
- Additional context and references
|
||||
|
||||
## Full Compiled Document
|
||||
|
||||
For the complete guide with all rules expanded: `AGENTS.md`
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "1.8.9",
|
||||
"organization": "**NAP-DMS / LCBP3** — Laem Chabang Port Phase 3 Document Management System",
|
||||
"date": "2026-04-22",
|
||||
"abstract": "Comprehensive NestJS best-practices guide compiled for the LCBP3-DMS backend. Contains 40+ rules across 11 categories (10 general + 1 project-specific), prioritized by impact. Forked from Kadajett/nestjs-best-practices (v1.1.0) and aligned to LCBP3 ADRs: ADR-001 (workflow engine), ADR-002 (document numbering), ADR-007 (error handling), ADR-008 (notifications/BullMQ), ADR-009 (no TypeORM migrations), ADR-016 (security), ADR-018/020 (AI boundary), ADR-019 (hybrid UUID identifier — March 2026 pattern), and ADR-021 (workflow context).\n\nThis document is the single, consolidated reference used by Cascade and other AI coding agents when writing, reviewing, or refactoring backend code in this repository. All LCBP3-specific overrides live in section 11.",
|
||||
"references": [
|
||||
"[AGENTS.md (root)](../../../AGENTS.md) — canonical AI agent rules",
|
||||
"[CONTRIBUTING.md](../../../CONTRIBUTING.md) — spec authoring + PR process",
|
||||
"[ADR-001 Unified Workflow Engine](../../../specs/06-Decision-Records/ADR-001-unified-workflow-engine.md)",
|
||||
"[ADR-002 Document Numbering Strategy](../../../specs/06-Decision-Records/ADR-002-document-numbering-strategy.md)",
|
||||
"[ADR-007 Error Handling Strategy](../../../specs/06-Decision-Records/ADR-007-error-handling-strategy.md)",
|
||||
"[ADR-008 Email/Notification Strategy](../../../specs/06-Decision-Records/ADR-008-email-notification-strategy.md)",
|
||||
"[ADR-009 Database Migration Strategy](../../../specs/06-Decision-Records/ADR-009-database-migration-strategy.md)",
|
||||
"[ADR-016 Security & Authentication](../../../specs/06-Decision-Records/ADR-016-security-authentication.md)",
|
||||
"[ADR-018 AI Boundary](../../../specs/06-Decision-Records/ADR-018-ai-boundary.md)",
|
||||
"[ADR-019 Hybrid Identifier Strategy](../../../specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md)",
|
||||
"[ADR-020 AI Intelligence Integration](../../../specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md)",
|
||||
"[ADR-021 Workflow Context](../../../specs/06-Decision-Records/ADR-021-workflow-context.md)",
|
||||
"[Backend Engineering Guidelines](../../../specs/05-Engineering-Guidelines/05-02-backend-guidelines.md)",
|
||||
"[Schema — v1.8.0 Tables](../../../specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql)",
|
||||
"[Data Dictionary](../../../specs/03-Data-and-Storage/03-01-data-dictionary.md)",
|
||||
"Upstream: [Kadajett/nestjs-best-practices](https://github.com/Kadajett/nestjs-best-practices) v1.1.0"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
title: Use DTOs and Serialization for API Responses
|
||||
impact: MEDIUM
|
||||
impactDescription: Response DTOs prevent accidental data exposure and ensure consistency
|
||||
tags: api, dto, serialization, class-transformer
|
||||
---
|
||||
|
||||
## Use DTOs and Serialization for API Responses
|
||||
|
||||
Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract.
|
||||
|
||||
**Incorrect (returning entities directly or manual spreading):**
|
||||
|
||||
```typescript
|
||||
// Return entities directly
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
return this.usersService.findById(id);
|
||||
// Returns: { id, email, passwordHash, ssn, internalNotes, ... }
|
||||
// Exposes sensitive data!
|
||||
}
|
||||
}
|
||||
|
||||
// Manual object spreading (error-prone)
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const user = await this.usersService.findById(id);
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
// Easy to forget to exclude sensitive fields
|
||||
// Hard to maintain across endpoints
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use class-transformer with @Exclude and response DTOs):**
|
||||
|
||||
```typescript
|
||||
// Enable class-transformer globally
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
||||
await app.listen(3000);
|
||||
}
|
||||
|
||||
// Entity with serialization control
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
email: string;
|
||||
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
@Exclude() // Never include in responses
|
||||
passwordHash: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
@Exclude()
|
||||
ssn: string;
|
||||
|
||||
@Column({ default: false })
|
||||
@Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests
|
||||
isAdmin: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column()
|
||||
@Exclude()
|
||||
internalNotes: string;
|
||||
}
|
||||
|
||||
// Now returning entity is safe
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
return this.usersService.findById(id);
|
||||
// Returns: { id, email, name, createdAt }
|
||||
// Sensitive fields excluded automatically
|
||||
}
|
||||
}
|
||||
|
||||
// For different response shapes, use explicit DTOs
|
||||
export class UserResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
@Transform(({ obj }) => obj.posts?.length || 0)
|
||||
postCount: number;
|
||||
|
||||
constructor(partial: Partial<User>) {
|
||||
Object.assign(this, partial);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserDetailResponseDto extends UserResponseDto {
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
@Type(() => PostResponseDto)
|
||||
posts: PostResponseDto[];
|
||||
}
|
||||
|
||||
// Controller with explicit DTOs
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get()
|
||||
@SerializeOptions({ type: UserResponseDto })
|
||||
async findAll(): Promise<UserResponseDto[]> {
|
||||
const users = await this.usersService.findAll();
|
||||
return users.map((u) => plainToInstance(UserResponseDto, u));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> {
|
||||
const user = await this.usersService.findByIdWithPosts(id);
|
||||
return plainToInstance(UserDetailResponseDto, user, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Groups for conditional serialization
|
||||
export class UserDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose({ groups: ['admin'] })
|
||||
email: string;
|
||||
|
||||
@Expose({ groups: ['admin'] })
|
||||
createdAt: Date;
|
||||
|
||||
@Expose({ groups: ['admin', 'owner'] })
|
||||
settings: UserSettings;
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get()
|
||||
@SerializeOptions({ groups: ['public'] })
|
||||
async findAllPublic(): Promise<UserDto[]> {
|
||||
// Returns: { id, name }
|
||||
}
|
||||
|
||||
@Get('admin')
|
||||
@UseGuards(AdminGuard)
|
||||
@SerializeOptions({ groups: ['admin'] })
|
||||
async findAllAdmin(): Promise<UserDto[]> {
|
||||
// Returns: { id, name, email, createdAt }
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@SerializeOptions({ groups: ['owner'] })
|
||||
async getProfile(@CurrentUser() user: User): Promise<UserDto> {
|
||||
// Returns: { id, name, settings }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization)
|
||||
@@ -0,0 +1,202 @@
|
||||
---
|
||||
title: Use Interceptors for Cross-Cutting Concerns
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Interceptors provide clean separation for cross-cutting logic
|
||||
tags: api, interceptors, logging, caching
|
||||
---
|
||||
|
||||
## Use Interceptors for Cross-Cutting Concerns
|
||||
|
||||
Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams.
|
||||
|
||||
**Incorrect (logging and transformation in every method):**
|
||||
|
||||
```typescript
|
||||
// Logging in every controller method
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get()
|
||||
async findAll(): Promise<User[]> {
|
||||
const start = Date.now();
|
||||
this.logger.log('findAll called');
|
||||
|
||||
const users = await this.usersService.findAll();
|
||||
|
||||
this.logger.log(`findAll completed in ${Date.now() - start}ms`);
|
||||
return users;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
const start = Date.now();
|
||||
this.logger.log(`findOne called with id: ${id}`);
|
||||
|
||||
const user = await this.usersService.findOne(id);
|
||||
|
||||
this.logger.log(`findOne completed in ${Date.now() - start}ms`);
|
||||
return user;
|
||||
}
|
||||
// Repeated in every method!
|
||||
}
|
||||
|
||||
// Manual response wrapping
|
||||
@Get()
|
||||
async findAll(): Promise<{ data: User[]; meta: Meta }> {
|
||||
const users = await this.usersService.findAll();
|
||||
return {
|
||||
data: users,
|
||||
meta: { timestamp: new Date(), count: users.length },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use interceptors for cross-cutting concerns):**
|
||||
|
||||
```typescript
|
||||
// Logging interceptor
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { method, url, body } = request;
|
||||
const now = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: (data) => {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
this.logger.log(
|
||||
`${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`,
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
this.logger.error(
|
||||
`${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`,
|
||||
error.stack,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Response transformation interceptor
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
data,
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
path: context.switchToHttp().getRequest().url,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout interceptor
|
||||
@Injectable()
|
||||
export class TimeoutInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
timeout(5000),
|
||||
catchError((err) => {
|
||||
if (err instanceof TimeoutError) {
|
||||
throw new RequestTimeoutException('Request timed out');
|
||||
}
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply globally or per-controller
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
|
||||
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Or per-controller
|
||||
@Controller('users')
|
||||
@UseInterceptors(LoggingInterceptor)
|
||||
export class UsersController {
|
||||
@Get()
|
||||
async findAll(): Promise<User[]> {
|
||||
// Clean business logic only
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Custom cache interceptor with TTL
|
||||
@Injectable()
|
||||
export class HttpCacheInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private cacheManager: Cache,
|
||||
private reflector: Reflector,
|
||||
) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Only cache GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const cacheKey = this.generateKey(request);
|
||||
const ttl = this.reflector.get<number>('cacheTTL', context.getHandler()) || 300;
|
||||
|
||||
const cached = await this.cacheManager.get(cacheKey);
|
||||
if (cached) {
|
||||
return of(cached);
|
||||
}
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((response) => {
|
||||
this.cacheManager.set(cacheKey, response, ttl);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private generateKey(request: Request): string {
|
||||
return `cache:${request.url}:${JSON.stringify(request.query)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage with custom TTL
|
||||
@Get()
|
||||
@SetMetadata('cacheTTL', 600)
|
||||
@UseInterceptors(HttpCacheInterceptor)
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
// Error mapping interceptor
|
||||
@Injectable()
|
||||
export class ErrorMappingInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
catchError((error) => {
|
||||
if (error instanceof EntityNotFoundError) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
if (error instanceof QueryFailedError) {
|
||||
if (error.message.includes('duplicate')) {
|
||||
throw new ConflictException('Resource already exists');
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Interceptors](https://docs.nestjs.com/interceptors)
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: Use Pipes for Input Transformation
|
||||
impact: MEDIUM
|
||||
impactDescription: Pipes ensure clean, validated data reaches your handlers
|
||||
tags: api, pipes, validation, transformation
|
||||
---
|
||||
|
||||
## Use Pipes for Input Transformation
|
||||
|
||||
Use built-in pipes like `ParseIntPipe`, `ParseUUIDPipe`, and `DefaultValuePipe` for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers.
|
||||
|
||||
**Incorrect (manual type parsing in handlers):**
|
||||
|
||||
```typescript
|
||||
// Manual type parsing in handlers
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
// Manual validation in every handler
|
||||
const uuid = id.trim();
|
||||
if (!isUUID(uuid)) {
|
||||
throw new BadRequestException('Invalid UUID');
|
||||
}
|
||||
return this.usersService.findOne(uuid);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@Query('page') page: string,
|
||||
@Query('limit') limit: string,
|
||||
): Promise<User[]> {
|
||||
// Manual parsing and defaults
|
||||
const pageNum = parseInt(page) || 1;
|
||||
const limitNum = parseInt(limit) || 10;
|
||||
return this.usersService.findAll(pageNum, limitNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Type coercion without validation
|
||||
@Get()
|
||||
async search(@Query('price') price: string): Promise<Product[]> {
|
||||
const priceNum = +price; // NaN if invalid, no error
|
||||
return this.productsService.findByPrice(priceNum);
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use built-in and custom pipes):**
|
||||
|
||||
```typescript
|
||||
// Use built-in pipes for common transformations
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
|
||||
// id is guaranteed to be a valid UUID
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
): Promise<User[]> {
|
||||
// Automatic defaults and type conversion
|
||||
return this.usersService.findAll(page, limit);
|
||||
}
|
||||
|
||||
@Get('by-status/:status')
|
||||
async findByStatus(
|
||||
@Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus,
|
||||
): Promise<User[]> {
|
||||
return this.usersService.findByStatus(status);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom pipe for business logic
|
||||
@Injectable()
|
||||
export class ParseDatePipe implements PipeTransform<string, Date> {
|
||||
transform(value: string): Date {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
throw new BadRequestException('Invalid date format');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
@Get('reports')
|
||||
async getReports(
|
||||
@Query('from', ParseDatePipe) from: Date,
|
||||
@Query('to', ParseDatePipe) to: Date,
|
||||
): Promise<Report[]> {
|
||||
return this.reportsService.findBetween(from, to);
|
||||
}
|
||||
|
||||
// Custom transformation pipes
|
||||
@Injectable()
|
||||
export class NormalizeEmailPipe implements PipeTransform<string, string> {
|
||||
transform(value: string): string {
|
||||
if (!value) return value;
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse comma-separated values
|
||||
@Injectable()
|
||||
export class ParseArrayPipe implements PipeTransform<string, string[]> {
|
||||
transform(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(',').map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('products')
|
||||
async findProducts(
|
||||
@Query('ids', ParseArrayPipe) ids: string[],
|
||||
@Query('email', NormalizeEmailPipe) email: string,
|
||||
): Promise<Product[]> {
|
||||
// ids is already an array, email is normalized
|
||||
return this.productsService.findByIds(ids);
|
||||
}
|
||||
|
||||
// Sanitize HTML input
|
||||
@Injectable()
|
||||
export class SanitizeHtmlPipe implements PipeTransform<string, string> {
|
||||
transform(value: string): string {
|
||||
if (!value) return value;
|
||||
return sanitizeHtml(value, { allowedTags: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// Global validation pipe with transformation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // Strip non-DTO properties
|
||||
transform: true, // Auto-transform to DTO types
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true, // Convert query strings to numbers
|
||||
},
|
||||
forbidNonWhitelisted: true, // Throw on extra properties
|
||||
}),
|
||||
);
|
||||
|
||||
// DTO with transformation decorators
|
||||
export class FindProductsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value?.split(','))
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@Query() dto: FindProductsDto): Promise<Product[]> {
|
||||
// dto is already transformed and validated
|
||||
return this.productsService.findAll(dto);
|
||||
}
|
||||
|
||||
// Pipe error customization
|
||||
@Injectable()
|
||||
export class CustomParseIntPipe extends ParseIntPipe {
|
||||
constructor() {
|
||||
super({
|
||||
exceptionFactory: (error) =>
|
||||
new BadRequestException(`${error} must be a valid integer`),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Or use options on built-in pipes
|
||||
@Get(':id')
|
||||
async findOne(
|
||||
@Param(
|
||||
'id',
|
||||
new ParseIntPipe({
|
||||
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
|
||||
exceptionFactory: () => new NotAcceptableException('ID must be numeric'),
|
||||
}),
|
||||
)
|
||||
id: number,
|
||||
): Promise<Item> {
|
||||
return this.itemsService.findOne(id);
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Pipes](https://docs.nestjs.com/pipes)
|
||||
@@ -0,0 +1,188 @@
|
||||
---
|
||||
title: Use API Versioning for Breaking Changes
|
||||
impact: MEDIUM
|
||||
impactDescription: Versioning allows you to evolve APIs without breaking existing clients
|
||||
tags: api, versioning, breaking-changes, compatibility
|
||||
---
|
||||
|
||||
## Use API Versioning for Breaking Changes
|
||||
|
||||
Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints.
|
||||
|
||||
**Incorrect (breaking changes without versioning):**
|
||||
|
||||
```typescript
|
||||
// Breaking changes without versioning
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
// Original response: { id, name, email }
|
||||
// Later changed to: { id, firstName, lastName, emailAddress }
|
||||
// Old clients break!
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual versioning in routes
|
||||
@Controller('v1/users')
|
||||
export class UsersV1Controller {}
|
||||
|
||||
@Controller('v2/users')
|
||||
export class UsersV2Controller {}
|
||||
// Inconsistent, error-prone, hard to maintain
|
||||
```
|
||||
|
||||
**Correct (use NestJS built-in versioning):**
|
||||
|
||||
```typescript
|
||||
// Enable versioning in main.ts
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// URI versioning: /v1/users, /v2/users
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
defaultVersion: '1',
|
||||
});
|
||||
|
||||
// Or header versioning: X-API-Version: 1
|
||||
app.enableVersioning({
|
||||
type: VersioningType.HEADER,
|
||||
header: 'X-API-Version',
|
||||
defaultVersion: '1',
|
||||
});
|
||||
|
||||
// Or media type: Accept: application/json;v=1
|
||||
app.enableVersioning({
|
||||
type: VersioningType.MEDIA_TYPE,
|
||||
key: 'v=',
|
||||
defaultVersion: '1',
|
||||
});
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
|
||||
// Version-specific controllers
|
||||
@Controller('users')
|
||||
@Version('1')
|
||||
export class UsersV1Controller {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<UserV1Response> {
|
||||
const user = await this.usersService.findOne(id);
|
||||
// V1 response format
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
@Version('2')
|
||||
export class UsersV2Controller {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<UserV2Response> {
|
||||
const user = await this.usersService.findOne(id);
|
||||
// V2 response format with breaking changes
|
||||
return {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
emailAddress: user.email,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Per-route versioning - different versions for different routes
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get()
|
||||
@Version('1')
|
||||
findAllV1(): Promise<UserV1Response[]> {
|
||||
return this.usersService.findAllV1();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Version('2')
|
||||
findAllV2(): Promise<UserV2Response[]> {
|
||||
return this.usersService.findAllV2();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Version(['1', '2']) // Same handler for multiple versions
|
||||
findOne(@Param('id') id: string): Promise<User> {
|
||||
return this.usersService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Version(VERSION_NEUTRAL) // Available in all versions
|
||||
create(@Body() dto: CreateUserDto): Promise<User> {
|
||||
return this.usersService.create(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared service with version-specific logic
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async findOne(id: string, version: string): Promise<any> {
|
||||
const user = await this.repo.findOne({ where: { id } });
|
||||
|
||||
if (version === '1') {
|
||||
return this.toV1Response(user);
|
||||
}
|
||||
return this.toV2Response(user);
|
||||
}
|
||||
|
||||
private toV1Response(user: User): UserV1Response {
|
||||
return {
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
private toV2Response(user: User): UserV2Response {
|
||||
return {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
emailAddress: user.email,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Controller extracts version
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @Headers('X-API-Version') version: string = '1'): Promise<any> {
|
||||
return this.usersService.findOne(id, version);
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecation strategy - mark old versions as deprecated
|
||||
@Controller('users')
|
||||
@Version('1')
|
||||
@UseInterceptors(DeprecationInterceptor)
|
||||
export class UsersV1Controller {
|
||||
// All V1 routes will include deprecation warning
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeprecationInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const response = context.switchToHttp().getResponse();
|
||||
response.setHeader('Deprecation', 'true');
|
||||
response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT');
|
||||
response.setHeader('Link', '</v2/users>; rel="successor-version"');
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Avoid Circular Dependencies
|
||||
impact: CRITICAL
|
||||
impactDescription: '#1 cause of runtime crashes'
|
||||
tags: architecture, modules, dependencies
|
||||
---
|
||||
|
||||
## Avoid Circular Dependencies
|
||||
|
||||
Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications.
|
||||
|
||||
**Incorrect (circular module imports):**
|
||||
|
||||
```typescript
|
||||
// users.module.ts
|
||||
@Module({
|
||||
imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
// orders.module.ts
|
||||
@Module({
|
||||
imports: [UsersModule], // Circular dependency!
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
```
|
||||
|
||||
**Correct (extract shared logic or use events):**
|
||||
|
||||
```typescript
|
||||
// Option 1: Extract shared logic to a third module
|
||||
// shared.module.ts
|
||||
@Module({
|
||||
providers: [SharedService],
|
||||
exports: [SharedService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
// users.module.ts
|
||||
@Module({
|
||||
imports: [SharedModule],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
// orders.module.ts
|
||||
@Module({
|
||||
imports: [SharedModule],
|
||||
providers: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
|
||||
// Option 2: Use events for decoupled communication
|
||||
// users.service.ts
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private eventEmitter: EventEmitter2) {}
|
||||
|
||||
async createUser(data: CreateUserDto) {
|
||||
const user = await this.userRepo.save(data);
|
||||
this.eventEmitter.emit('user.created', user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// orders.service.ts
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
@OnEvent('user.created')
|
||||
handleUserCreated(user: User) {
|
||||
// React to user creation without direct dependency
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Circular Dependency](https://docs.nestjs.com/fundamentals/circular-dependency)
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
title: Organize by Feature Modules
|
||||
impact: CRITICAL
|
||||
impactDescription: '3-5x faster onboarding and development'
|
||||
tags: architecture, modules, organization
|
||||
---
|
||||
|
||||
## Organize by Feature Modules
|
||||
|
||||
Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development.
|
||||
|
||||
**Incorrect (technical layer organization):**
|
||||
|
||||
```typescript
|
||||
// Technical layer organization (anti-pattern)
|
||||
src/
|
||||
├── controllers/
|
||||
│ ├── users.controller.ts
|
||||
│ ├── orders.controller.ts
|
||||
│ └── products.controller.ts
|
||||
├── services/
|
||||
│ ├── users.service.ts
|
||||
│ ├── orders.service.ts
|
||||
│ └── products.service.ts
|
||||
├── entities/
|
||||
│ ├── user.entity.ts
|
||||
│ ├── order.entity.ts
|
||||
│ └── product.entity.ts
|
||||
└── app.module.ts // Imports everything directly
|
||||
```
|
||||
|
||||
**Correct (feature module organization):**
|
||||
|
||||
```typescript
|
||||
// Feature module organization
|
||||
src/
|
||||
├── users/
|
||||
│ ├── dto/
|
||||
│ │ ├── create-user.dto.ts
|
||||
│ │ └── update-user.dto.ts
|
||||
│ ├── entities/
|
||||
│ │ └── user.entity.ts
|
||||
│ ├── users.controller.ts
|
||||
│ ├── users.service.ts
|
||||
│ ├── users.repository.ts
|
||||
│ └── users.module.ts
|
||||
├── orders/
|
||||
│ ├── dto/
|
||||
│ ├── entities/
|
||||
│ ├── orders.controller.ts
|
||||
│ ├── orders.service.ts
|
||||
│ └── orders.module.ts
|
||||
├── shared/
|
||||
│ ├── guards/
|
||||
│ ├── interceptors/
|
||||
│ ├── filters/
|
||||
│ └── shared.module.ts
|
||||
└── app.module.ts
|
||||
|
||||
// users.module.ts
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User])],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, UsersRepository],
|
||||
exports: [UsersService], // Only export what others need
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
// app.module.ts
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot(),
|
||||
TypeOrmModule.forRoot(),
|
||||
UsersModule,
|
||||
OrdersModule,
|
||||
SharedModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
Reference: [NestJS Modules](https://docs.nestjs.com/modules)
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Use Proper Module Sharing Patterns
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevents duplicate instances, memory leaks, and state inconsistency
|
||||
tags: architecture, modules, sharing, exports
|
||||
---
|
||||
|
||||
## Use Proper Module Sharing Patterns
|
||||
|
||||
NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed.
|
||||
|
||||
**Incorrect (service provided in multiple modules):**
|
||||
|
||||
```typescript
|
||||
// StorageService provided directly in multiple modules - WRONG
|
||||
// storage.service.ts
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private cache = new Map(); // Each instance has separate state!
|
||||
|
||||
store(key: string, value: any) {
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// app.module.ts
|
||||
@Module({
|
||||
providers: [StorageService], // Instance #1
|
||||
controllers: [AppController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// videos.module.ts
|
||||
@Module({
|
||||
providers: [StorageService], // Instance #2 - different from AppModule!
|
||||
controllers: [VideosController],
|
||||
})
|
||||
export class VideosModule {}
|
||||
|
||||
// Problems:
|
||||
// 1. Two separate StorageService instances exist
|
||||
// 2. cache.set() in VideosModule doesn't affect AppModule's cache
|
||||
// 3. Memory wasted on duplicate instances
|
||||
// 4. Debugging nightmares when state doesn't sync
|
||||
```
|
||||
|
||||
**Correct (dedicated module with exports):**
|
||||
|
||||
```typescript
|
||||
// storage/storage.module.ts
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService], // Make available to importers
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
// videos/videos.module.ts
|
||||
@Module({
|
||||
imports: [StorageModule], // Import the module, not the service
|
||||
controllers: [VideosController],
|
||||
providers: [VideosService],
|
||||
})
|
||||
export class VideosModule {}
|
||||
|
||||
// channels/channels.module.ts
|
||||
@Module({
|
||||
imports: [StorageModule], // Same instance shared
|
||||
controllers: [ChannelsController],
|
||||
providers: [ChannelsService],
|
||||
})
|
||||
export class ChannelsModule {}
|
||||
|
||||
// app.module.ts
|
||||
@Module({
|
||||
imports: [
|
||||
StorageModule, // Only if AppModule itself needs StorageService
|
||||
VideosModule,
|
||||
ChannelsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Now all modules share the SAME StorageService instance
|
||||
```
|
||||
|
||||
**When to use @Global() (sparingly):**
|
||||
|
||||
```typescript
|
||||
// ONLY for truly cross-cutting concerns
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ConfigService, LoggerService],
|
||||
exports: [ConfigService, LoggerService],
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
// Import once in AppModule
|
||||
@Module({
|
||||
imports: [CoreModule], // Registered globally, available everywhere
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Other modules don't need to import CoreModule
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService], // Can inject ConfigService without importing
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
// WARNING: Don't make everything global!
|
||||
// - Hides dependencies (can't see what a module needs from imports)
|
||||
// - Makes testing harder
|
||||
// - Reserve for: config, logging, database connections
|
||||
```
|
||||
|
||||
**Module re-exporting pattern:**
|
||||
|
||||
```typescript
|
||||
// common.module.ts - shared utilities
|
||||
@Module({
|
||||
providers: [DateService, ValidationService],
|
||||
exports: [DateService, ValidationService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
// core.module.ts - re-exports common for convenience
|
||||
@Module({
|
||||
imports: [CommonModule, DatabaseModule],
|
||||
exports: [CommonModule, DatabaseModule], // Re-export for consumers
|
||||
})
|
||||
export class CoreModule {}
|
||||
|
||||
// feature.module.ts - imports CoreModule, gets both
|
||||
@Module({
|
||||
imports: [CoreModule], // Gets CommonModule + DatabaseModule
|
||||
controllers: [FeatureController],
|
||||
})
|
||||
export class FeatureModule {}
|
||||
```
|
||||
|
||||
Reference: [NestJS Modules](https://docs.nestjs.com/modules#shared-modules)
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Single Responsibility for Services
|
||||
impact: CRITICAL
|
||||
impactDescription: '40%+ improvement in testability'
|
||||
tags: architecture, services, single-responsibility
|
||||
---
|
||||
|
||||
## Single Responsibility for Services
|
||||
|
||||
Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+.
|
||||
|
||||
**Incorrect (god service anti-pattern):**
|
||||
|
||||
```typescript
|
||||
// God service anti-pattern
|
||||
@Injectable()
|
||||
export class UserAndOrderService {
|
||||
constructor(
|
||||
private userRepo: UserRepository,
|
||||
private orderRepo: OrderRepository,
|
||||
private mailer: MailService,
|
||||
private payment: PaymentService
|
||||
) {}
|
||||
|
||||
async createUser(dto: CreateUserDto) {
|
||||
const user = await this.userRepo.save(dto);
|
||||
await this.mailer.sendWelcome(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async createOrder(userId: string, dto: CreateOrderDto) {
|
||||
const order = await this.orderRepo.save({ userId, ...dto });
|
||||
await this.payment.charge(order);
|
||||
await this.mailer.sendOrderConfirmation(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
async calculateOrderStats(userId: string) {
|
||||
// Stats logic mixed in
|
||||
}
|
||||
|
||||
async validatePayment(orderId: string) {
|
||||
// Payment logic mixed in
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (focused services with single responsibility):**
|
||||
|
||||
```typescript
|
||||
// Focused services with single responsibility
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private userRepo: UserRepository) {}
|
||||
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
return this.userRepo.save(dto);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User> {
|
||||
return this.userRepo.findOneOrFail({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private orderRepo: OrderRepository) {}
|
||||
|
||||
async create(userId: string, dto: CreateOrderDto): Promise<Order> {
|
||||
return this.orderRepo.save({ userId, ...dto });
|
||||
}
|
||||
|
||||
async findByUser(userId: string): Promise<Order[]> {
|
||||
return this.orderRepo.find({ where: { userId } });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrderStatsService {
|
||||
constructor(private orderRepo: OrderRepository) {}
|
||||
|
||||
async calculateForUser(userId: string): Promise<OrderStats> {
|
||||
// Focused stats calculation
|
||||
}
|
||||
}
|
||||
|
||||
// Orchestration in controller or dedicated orchestrator
|
||||
@Controller('orders')
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private orders: OrdersService,
|
||||
private payment: PaymentService,
|
||||
private notifications: NotificationService
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) {
|
||||
const order = await this.orders.create(user.id, dto);
|
||||
await this.payment.charge(order);
|
||||
await this.notifications.sendOrderConfirmation(order);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Providers](https://docs.nestjs.com/providers)
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Use Event-Driven Architecture for Decoupling
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Enables async processing and modularity
|
||||
tags: architecture, events, decoupling
|
||||
---
|
||||
|
||||
## Use Event-Driven Architecture for Decoupling
|
||||
|
||||
Use `@nestjs/event-emitter` for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing.
|
||||
|
||||
**Incorrect (direct service coupling):**
|
||||
|
||||
```typescript
|
||||
// Direct service coupling
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private inventoryService: InventoryService,
|
||||
private emailService: EmailService,
|
||||
private analyticsService: AnalyticsService,
|
||||
private notificationService: NotificationService,
|
||||
private loyaltyService: LoyaltyService
|
||||
) {}
|
||||
|
||||
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
||||
const order = await this.repo.save(dto);
|
||||
|
||||
// Tight coupling - OrdersService knows about all consumers
|
||||
await this.inventoryService.reserve(order.items);
|
||||
await this.emailService.sendConfirmation(order);
|
||||
await this.analyticsService.track('order_created', order);
|
||||
await this.notificationService.push(order.userId, 'Order placed');
|
||||
await this.loyaltyService.addPoints(order.userId, order.total);
|
||||
|
||||
// Adding new behavior requires modifying this service
|
||||
return order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (event-driven decoupling):**
|
||||
|
||||
```typescript
|
||||
// Use EventEmitter for decoupling
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
// Define event
|
||||
export class OrderCreatedEvent {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly userId: string,
|
||||
public readonly items: OrderItem[],
|
||||
public readonly total: number
|
||||
) {}
|
||||
}
|
||||
|
||||
// Service emits events
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private eventEmitter: EventEmitter2,
|
||||
private repo: Repository<Order>
|
||||
) {}
|
||||
|
||||
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
||||
const order = await this.repo.save(dto);
|
||||
|
||||
// Emit event - no knowledge of consumers
|
||||
this.eventEmitter.emit('order.created', new OrderCreatedEvent(order.id, order.userId, order.items, order.total));
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
// Listeners in separate modules
|
||||
@Injectable()
|
||||
export class InventoryListener {
|
||||
@OnEvent('order.created')
|
||||
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
|
||||
await this.inventoryService.reserve(event.items);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailListener {
|
||||
@OnEvent('order.created')
|
||||
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
|
||||
await this.emailService.sendConfirmation(event.orderId);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsListener {
|
||||
@OnEvent('order.created')
|
||||
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
|
||||
await this.analyticsService.track('order_created', {
|
||||
orderId: event.orderId,
|
||||
total: event.total,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Events](https://docs.nestjs.com/techniques/events)
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Use Repository Pattern for Data Access
|
||||
impact: HIGH
|
||||
impactDescription: Decouples business logic from database
|
||||
tags: architecture, repository, data-access
|
||||
---
|
||||
|
||||
## Use Repository Pattern for Data Access
|
||||
|
||||
Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code.
|
||||
|
||||
**Incorrect (complex queries in services):**
|
||||
|
||||
```typescript
|
||||
// Complex queries in services
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(@InjectRepository(User) private repo: Repository<User>) {}
|
||||
|
||||
async findActiveWithOrders(minOrders: number): Promise<User[]> {
|
||||
// Complex query logic mixed with business logic
|
||||
return this.repo
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.orders', 'order')
|
||||
.where('user.isActive = :active', { active: true })
|
||||
.andWhere('user.deletedAt IS NULL')
|
||||
.groupBy('user.id')
|
||||
.having('COUNT(order.id) >= :min', { min: minOrders })
|
||||
.orderBy('user.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
// Service becomes bloated with query logic
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (custom repository with encapsulated queries):**
|
||||
|
||||
```typescript
|
||||
// Custom repository with encapsulated queries
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
constructor(@InjectRepository(User) private repo: Repository<User>) {}
|
||||
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.repo.findOne({ where: { email } });
|
||||
}
|
||||
|
||||
async findActiveWithMinOrders(minOrders: number): Promise<User[]> {
|
||||
return this.repo
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.orders', 'order')
|
||||
.where('user.isActive = :active', { active: true })
|
||||
.andWhere('user.deletedAt IS NULL')
|
||||
.groupBy('user.id')
|
||||
.having('COUNT(order.id) >= :min', { min: minOrders })
|
||||
.orderBy('user.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async save(user: User): Promise<User> {
|
||||
return this.repo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean service with business logic only
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private usersRepo: UsersRepository) {}
|
||||
|
||||
async getActiveUsersWithOrders(): Promise<User[]> {
|
||||
return this.usersRepo.findActiveWithMinOrders(1);
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
const existing = await this.usersRepo.findByEmail(dto.email);
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
const user = new User();
|
||||
user.email = dto.email;
|
||||
user.name = dto.name;
|
||||
return this.usersRepo.save(user);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Repository Pattern](https://martinfowler.com/eaaCatalog/repository.html)
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Avoid N+1 Query Problems
|
||||
impact: HIGH
|
||||
impactDescription: N+1 queries are one of the most common performance killers
|
||||
tags: database, n-plus-one, queries, performance
|
||||
---
|
||||
|
||||
## Avoid N+1 Query Problems
|
||||
|
||||
N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with `relations`, query builder joins, or DataLoader to batch queries efficiently.
|
||||
|
||||
**Incorrect (lazy loading in loops causes N+1):**
|
||||
|
||||
```typescript
|
||||
// Lazy loading in loops causes N+1
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
async getOrdersWithItems(userId: string): Promise<Order[]> {
|
||||
const orders = await this.orderRepo.find({ where: { userId } });
|
||||
// 1 query for orders
|
||||
|
||||
for (const order of orders) {
|
||||
// N additional queries - one per order!
|
||||
order.items = await this.itemRepo.find({ where: { orderId: order.id } });
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
}
|
||||
|
||||
// Accessing lazy relations without loading
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get()
|
||||
async findAll(): Promise<User[]> {
|
||||
const users = await this.userRepo.find();
|
||||
// If User.posts is lazy-loaded, serializing triggers N queries
|
||||
return users; // Each user.posts access = 1 query
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use relations for eager loading):**
|
||||
|
||||
```typescript
|
||||
// Use relations option for eager loading
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
async getOrdersWithItems(userId: string): Promise<Order[]> {
|
||||
// Single query with JOIN
|
||||
return this.orderRepo.find({
|
||||
where: { userId },
|
||||
relations: ['items', 'items.product'],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use QueryBuilder for complex joins
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async getUsersWithPostCounts(): Promise<UserWithPostCount[]> {
|
||||
return this.userRepo
|
||||
.createQueryBuilder('user')
|
||||
.leftJoin('user.posts', 'post')
|
||||
.select('user.id', 'id')
|
||||
.addSelect('user.name', 'name')
|
||||
.addSelect('COUNT(post.id)', 'postCount')
|
||||
.groupBy('user.id')
|
||||
.getRawMany();
|
||||
}
|
||||
|
||||
async getActiveUsersWithPosts(): Promise<User[]> {
|
||||
return this.userRepo
|
||||
.createQueryBuilder('user')
|
||||
.leftJoinAndSelect('user.posts', 'post')
|
||||
.leftJoinAndSelect('post.comments', 'comment')
|
||||
.where('user.isActive = :active', { active: true })
|
||||
.andWhere('post.status = :status', { status: 'published' })
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
// Use find options for specific fields
|
||||
async getOrderSummaries(userId: string): Promise<OrderSummary[]> {
|
||||
return this.orderRepo.find({
|
||||
where: { userId },
|
||||
relations: ['items'],
|
||||
select: {
|
||||
id: true,
|
||||
total: true,
|
||||
status: true,
|
||||
items: {
|
||||
id: true,
|
||||
quantity: true,
|
||||
price: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Use DataLoader for GraphQL to batch and cache queries
|
||||
import DataLoader from 'dataloader';
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class PostsLoader {
|
||||
constructor(private postsService: PostsService) {}
|
||||
|
||||
readonly batchPosts = new DataLoader<string, Post[]>(async (userIds) => {
|
||||
// Single query for all users' posts
|
||||
const posts = await this.postsService.findByUserIds([...userIds]);
|
||||
|
||||
// Group by userId
|
||||
const postsMap = new Map<string, Post[]>();
|
||||
for (const post of posts) {
|
||||
const userPosts = postsMap.get(post.userId) || [];
|
||||
userPosts.push(post);
|
||||
postsMap.set(post.userId, userPosts);
|
||||
}
|
||||
|
||||
// Return in same order as input
|
||||
return userIds.map((id) => postsMap.get(id) || []);
|
||||
});
|
||||
}
|
||||
|
||||
// In resolver
|
||||
@ResolveField()
|
||||
async posts(@Parent() user: User): Promise<Post[]> {
|
||||
// DataLoader batches multiple calls into single query
|
||||
return this.postsLoader.batchPosts.load(user.id);
|
||||
}
|
||||
|
||||
// Enable query logging in development to detect N+1
|
||||
TypeOrmModule.forRoot({
|
||||
logging: ['query', 'error'],
|
||||
logger: 'advanced-console',
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [TypeORM Relations](https://typeorm.io/relations)
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Hybrid Identifier Strategy (ADR-019)
|
||||
impact: CRITICAL
|
||||
impactDescription: Use INT PK internally + UUID for public API per project ADR-019
|
||||
tags: database, uuid, identifier, adr-019, api-design, typeorm
|
||||
---
|
||||
|
||||
## Hybrid Identifier Strategy (ADR-019) — March 2026 Pattern
|
||||
|
||||
**This project follows ADR-019: INT Primary Key (internal) + UUIDv7 (public API)**
|
||||
|
||||
Unlike standard practices that use UUID as the primary key, this project uses a **hybrid approach** optimized for MariaDB performance and API consistency.
|
||||
|
||||
> **Updated pattern (March 2026):** Entities extend `UuidBaseEntity`. The `publicId` column is exposed **directly** in API responses — ห้ามใช้ `@Expose({ name: 'id' })` เพื่อ rename.
|
||||
|
||||
### The Strategy
|
||||
|
||||
| Layer | Field | Type | Usage |
|
||||
| --------------- | ---------- | ----------------------------------- | ------------------------------------------------- |
|
||||
| **Database PK** | `id` | `INT AUTO_INCREMENT` | Internal foreign keys only (marked `@Exclude()`) |
|
||||
| **Public API** | `publicId` | `MariaDB UUID` (native, BINARY(16)) | External references, URLs — exposed as-is |
|
||||
| **DTO Input** | `xxxUuid` | `string` (UUIDv7) | Accept UUID in create/update DTOs |
|
||||
| **DTO Output** | `publicId` | `string` (UUIDv7) | API returns `publicId` field directly (no rename) |
|
||||
|
||||
### Why Hybrid IDs?
|
||||
|
||||
- **Performance**: INT PK is faster for joins and indexing than UUID
|
||||
- **Security**: Internal IDs never exposed in API (enumerable IDs are a risk)
|
||||
- **Compatibility**: UUID works well with distributed systems and external integrations
|
||||
- **MariaDB Native**: Uses MariaDB's native UUID type (stored as BINARY(16), auto-converts to string)
|
||||
|
||||
### Entity Definition (Current Pattern)
|
||||
|
||||
```typescript
|
||||
import { Entity, Column } from 'typeorm';
|
||||
import { UuidBaseEntity } from '@/common/entities/uuid-base.entity';
|
||||
|
||||
@Entity('contracts')
|
||||
export class Contract extends UuidBaseEntity {
|
||||
// publicId (string UUIDv7) + id (INT, @Exclude) สืบทอดจาก UuidBaseEntity
|
||||
// API response → { publicId: "019505a1-7c3e-7000-8000-abc123...", contractCode: ..., ... }
|
||||
|
||||
@Column()
|
||||
contractCode: string;
|
||||
|
||||
@Column()
|
||||
contractName: string;
|
||||
|
||||
@Column({ name: 'project_id' })
|
||||
projectId: number; // INT FK — internal, not exposed if marked @Exclude in UuidBaseEntity
|
||||
}
|
||||
```
|
||||
|
||||
**`UuidBaseEntity` (shared base):**
|
||||
|
||||
```typescript
|
||||
import { PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Exclude } from 'class-transformer';
|
||||
|
||||
export abstract class UuidBaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
@Exclude() // ❗ CRITICAL: INT id must never leak to API
|
||||
id: number;
|
||||
|
||||
@Column({ type: 'uuid', unique: true, generated: 'uuid' })
|
||||
publicId: string; // UUIDv7, exposed as-is
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### DTO Pattern (Accept UUID, Resolve to INT Internally)
|
||||
|
||||
```typescript
|
||||
// dto/create-contract.dto.ts
|
||||
import { IsUUID, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CreateContractDto {
|
||||
@IsNotEmpty()
|
||||
@IsUUID('7') // UUIDv7 (MariaDB native)
|
||||
projectUuid: string; // Accept UUID from client
|
||||
|
||||
@IsNotEmpty()
|
||||
contractCode: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
contractName: string;
|
||||
}
|
||||
|
||||
// ❌ NO Response DTO with @Expose rename needed.
|
||||
// Entity class_transformer via TransformInterceptor will serialize publicId directly.
|
||||
```
|
||||
|
||||
### Service/Controller Pattern
|
||||
|
||||
```typescript
|
||||
@Controller('contracts')
|
||||
@UseGuards(JwtAuthGuard, CaslAbilityGuard)
|
||||
export class ContractsController {
|
||||
constructor(
|
||||
private contractsService: ContractsService,
|
||||
private uuidResolver: UuidResolver
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateContractDto) {
|
||||
// Resolve UUID → INT PK for FK relationship
|
||||
const projectId = await this.uuidResolver.resolveProject(dto.projectUuid);
|
||||
|
||||
const contract = await this.contractsService.create({
|
||||
...dto,
|
||||
projectId,
|
||||
});
|
||||
|
||||
// Response: TransformInterceptor + @Exclude on id → publicId exposed directly
|
||||
return contract;
|
||||
}
|
||||
|
||||
@Get(':publicId')
|
||||
async findOne(@Param('publicId', ParseUuidPipe) publicId: string) {
|
||||
return this.contractsService.findOneByPublicId(publicId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UUID Resolver Helper
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class UuidResolver {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private projectRepo: Repository<Project>,
|
||||
@InjectRepository(Contract)
|
||||
private contractRepo: Repository<Contract>
|
||||
) {}
|
||||
|
||||
async resolveProject(publicId: string): Promise<number> {
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { publicId },
|
||||
select: ['id'], // Only INT PK for FK
|
||||
});
|
||||
if (!project) throw new NotFoundException('Project not found');
|
||||
return project.id;
|
||||
}
|
||||
|
||||
async resolveContract(publicId: string): Promise<number> {
|
||||
const contract = await this.contractRepo.findOne({
|
||||
where: { publicId },
|
||||
select: ['id'],
|
||||
});
|
||||
if (!contract) throw new NotFoundException('Contract not found');
|
||||
return contract.id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### TransformInterceptor (Required — register ONCE)
|
||||
|
||||
```typescript
|
||||
// Register via APP_INTERCEPTOR in CommonModule — ห้ามซ้ำใน main.ts
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data) => instanceToPlain(data)) // Applies @Exclude / @Expose
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// common.module.ts
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: TransformInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CommonModule {}
|
||||
```
|
||||
|
||||
> **Warning:** ห้ามเรียก `app.useGlobalInterceptors(new TransformInterceptor())` ใน `main.ts` ซ้ำ — จะทำให้ response double-wrap `{ data: { data: ... } }`.
|
||||
|
||||
### Critical: NEVER ParseInt on UUID
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - parseInt on UUID gives garbage value
|
||||
const id = parseInt(projectPublicId); // "0195a1b2-..." → 195 (wrong!)
|
||||
|
||||
// ❌ WRONG - Number() on UUID
|
||||
const id = Number(projectPublicId); // NaN
|
||||
|
||||
// ❌ WRONG - Unary plus on UUID
|
||||
const id = +projectPublicId; // NaN
|
||||
|
||||
// ✅ CORRECT - Resolve via database lookup
|
||||
const projectId = await uuidResolver.resolveProject(projectPublicId);
|
||||
|
||||
// ✅ CORRECT - Use TypeORM find with publicId column
|
||||
const project = await projectRepo.findOne({ where: { publicId: projectPublicId } });
|
||||
const id = project.id; // Get INT PK from entity
|
||||
```
|
||||
|
||||
### Query with publicId (No Resolution Needed)
|
||||
|
||||
```typescript
|
||||
// Direct UUID lookup in TypeORM
|
||||
const project = await this.projectRepo.findOne({
|
||||
where: { publicId: projectPublicId },
|
||||
});
|
||||
|
||||
// Relations use INT FK internally
|
||||
const contracts = await this.contractRepo.find({
|
||||
where: { projectId: project.id }, // INT for FK query
|
||||
});
|
||||
```
|
||||
|
||||
### Reference
|
||||
|
||||
- [ADR-019 Hybrid Identifier Strategy](../../../../specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md)
|
||||
- [UUID Implementation Plan](../../../../specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md)
|
||||
- [Data Dictionary](../../../../specs/03-Data-and-Storage/03-01-data-dictionary.md)
|
||||
|
||||
> **Warning**: Using `parseInt()`, `Number()`, or unary `+` on UUID values violates ADR-019 and will cause data corruption. Always resolve UUIDs via database lookup.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: No TypeORM Migrations (ADR-009)
|
||||
impact: CRITICAL
|
||||
impactDescription: Edit SQL schema files directly; n8n handles data migration. Do not generate TypeORM migration files.
|
||||
tags: database, schema, migration, adr-009, sql, n8n
|
||||
---
|
||||
|
||||
## No TypeORM Migrations (ADR-009)
|
||||
|
||||
**This project does NOT use TypeORM migration files.**
|
||||
|
||||
All schema changes must be made **directly** in the canonical SQL file:
|
||||
|
||||
- `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
|
||||
|
||||
Delta scripts (for incremental rollout to existing environments) go under:
|
||||
|
||||
- `specs/03-Data-and-Storage/deltas/YYYY-MM-DD-descriptive-name.sql`
|
||||
|
||||
Data migration (e.g., backfilling a new column) is handled by **n8n workflows**, not TypeORM's `QueryRunner`.
|
||||
|
||||
---
|
||||
|
||||
## Why No Migrations?
|
||||
|
||||
1. **Single source of truth** — The full SQL schema is always readable as one file. No need to replay a migration chain to understand current state.
|
||||
2. **Review friendly** — Schema diff = git diff on the SQL file. Reviewers see the complete picture.
|
||||
3. **Ops alignment** — DBAs and operators work in SQL, not TypeScript.
|
||||
4. **n8n for data** — Business-meaningful data transforms live in n8n where they can be versioned, retried, and orchestrated with monitoring.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Workflow for a Schema Change
|
||||
|
||||
1. **Update Data Dictionary** first:
|
||||
- `specs/03-Data-and-Storage/03-01-data-dictionary.md` — add field meaning + business rules.
|
||||
2. **Update the canonical schema**:
|
||||
- Edit `lcbp3-v1.8.0-schema-02-tables.sql` — add/alter column, constraint, index.
|
||||
3. **Add a delta script** (if deploying to existing env):
|
||||
- `specs/03-Data-and-Storage/deltas/2026-04-22-add-rfa-revision-column.sql`
|
||||
|
||||
```sql
|
||||
-- Delta: Add revision column to rfa table
|
||||
ALTER TABLE rfa
|
||||
ADD COLUMN revision INT NOT NULL DEFAULT 1 AFTER status;
|
||||
|
||||
CREATE INDEX idx_rfa_revision ON rfa(revision);
|
||||
```
|
||||
4. **Update the Entity** (`backend/src/.../entities/rfa.entity.ts`):
|
||||
|
||||
```typescript
|
||||
@Column({ type: 'int', default: 1 })
|
||||
revision: number;
|
||||
```
|
||||
5. **If data backfill needed** → create n8n workflow, not TypeScript migration.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Forbidden
|
||||
|
||||
```bash
|
||||
# ❌ DO NOT generate migrations
|
||||
pnpm typeorm migration:generate ./src/migrations/AddRevision
|
||||
|
||||
# ❌ DO NOT run migrations
|
||||
pnpm typeorm migration:run
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ❌ DO NOT write migration classes
|
||||
export class AddRevision1730000000000 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> { /* ... */ }
|
||||
async down(queryRunner: QueryRunner): Promise<void> { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ TypeORM Config (runtime only)
|
||||
|
||||
```typescript
|
||||
// ormconfig.ts
|
||||
export default {
|
||||
type: 'mariadb',
|
||||
// ...
|
||||
synchronize: false, // ❗ NEVER true (would auto-sync entity ↔ schema)
|
||||
migrationsRun: false, // ❗ NEVER true
|
||||
// ❌ Do NOT specify `migrations:` entries
|
||||
};
|
||||
```
|
||||
|
||||
`synchronize: false` is mandatory because the canonical SQL file is authoritative — TypeORM should never mutate the schema.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-009 Database Migration Strategy](../../../../specs/06-Decision-Records/ADR-009-database-migration-strategy.md)
|
||||
- [Data Dictionary](../../../../specs/03-Data-and-Storage/03-01-data-dictionary.md)
|
||||
- [Schema Tables](../../../../specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql)
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: No TypeORM Migrations (ADR-009)
|
||||
impact: HIGH
|
||||
impactDescription: Use direct SQL schema files instead of TypeORM migrations per project ADR
|
||||
tags: database, schema, typeorm, migrations, adr-009
|
||||
---
|
||||
|
||||
## No TypeORM Migrations (ADR-009)
|
||||
|
||||
**This project follows ADR-009: Direct SQL Schema Management**
|
||||
|
||||
Unlike standard NestJS/TypeORM practices, this project does **NOT** use TypeORM migrations. Instead, we manage database schema through direct SQL files.
|
||||
|
||||
### Why No Migrations?
|
||||
|
||||
- **ADR-009 Decision**: Explicit schema control over auto-generated migrations
|
||||
- **MariaDB-specific features**: Native UUID type, virtual columns, custom indexing
|
||||
- **Team workflow**: Schema changes reviewed as SQL, not TypeORM migration classes
|
||||
- **Audit trail**: Single source of truth in `specs/03-Data-and-Storage/`
|
||||
|
||||
### Schema File Locations
|
||||
|
||||
```
|
||||
specs/03-Data-and-Storage/
|
||||
├── lcbp3-v1.8.0-schema-01-drop.sql # Drop statements (dev only)
|
||||
├── lcbp3-v1.8.0-schema-02-tables.sql # CREATE TABLE statements
|
||||
├── lcbp3-v1.8.0-schema-03-views-indexes.sql # Views, indexes, constraints
|
||||
└── deltas/ # Incremental changes
|
||||
├── 01-add-reference-date.sql
|
||||
├── 02-add-rbac-bulk-permission.sql
|
||||
└── 03-fix-numbering-enums.sql
|
||||
```
|
||||
|
||||
### Correct: Using SQL Schema Files
|
||||
|
||||
```typescript
|
||||
// TypeORM configuration - NO migrationsRun
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'mariadb',
|
||||
host: config.get('DB_HOST'),
|
||||
port: config.get('DB_PORT'),
|
||||
username: config.get('DB_USERNAME'),
|
||||
password: config.get('DB_PASSWORD'),
|
||||
database: config.get('DB_NAME'),
|
||||
entities: ['dist/**/*.entity.js'],
|
||||
synchronize: false, // NEVER true, even in development
|
||||
migrationsRun: false, // Disabled per ADR-009
|
||||
// Migrations are managed via SQL files, not TypeORM
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Schema Change Process (ADR-009)
|
||||
|
||||
1. **Modify SQL file directly**:
|
||||
|
||||
```sql
|
||||
-- specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql
|
||||
ALTER TABLE correspondences
|
||||
ADD COLUMN priority VARCHAR(20) DEFAULT 'normal';
|
||||
```
|
||||
|
||||
2. **Create delta for existing databases**:
|
||||
|
||||
```sql
|
||||
-- specs/03-Data-and-Storage/deltas/04-add-priority-column.sql
|
||||
ALTER TABLE correspondences
|
||||
ADD COLUMN priority VARCHAR(20) DEFAULT 'normal';
|
||||
```
|
||||
|
||||
3. **Apply to database manually or via deployment script**:
|
||||
```bash
|
||||
mysql -u root -p lcbp3 < specs/03-Data-and-Storage/deltas/04-add-priority-column.sql
|
||||
```
|
||||
|
||||
### Entity Definition (No Migration Needed)
|
||||
|
||||
```typescript
|
||||
@Entity('correspondences')
|
||||
export class Correspondence {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number; // Internal INT PK
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
uuid: string; // Public UUID
|
||||
|
||||
@Column({ name: 'priority', default: 'normal' })
|
||||
priority: string;
|
||||
|
||||
// No migration class needed - schema managed via SQL
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Pattern: TypeORM Migrations (Do NOT Use)
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG - Do not create migration files
|
||||
// migrations/1705312800000-AddUserAge.ts
|
||||
export class AddUserAge1705312800000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD "age" integer`);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ WRONG - Do not enable migrationsRun
|
||||
TypeOrmModule.forRoot({
|
||||
migrationsRun: true, // Disabled per ADR-009
|
||||
migrations: ['dist/migrations/*.js'],
|
||||
});
|
||||
```
|
||||
|
||||
### When You Need Schema Changes
|
||||
|
||||
1. Check `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql`
|
||||
2. Add your DDL to the appropriate SQL file
|
||||
3. Create delta file in `deltas/` directory
|
||||
4. Apply SQL to your database
|
||||
5. Update corresponding Entity class
|
||||
|
||||
### Reference
|
||||
|
||||
- [ADR-009 Database Strategy](../../../../specs/06-Decision-Records/ADR-009-db-strategy.md)
|
||||
- [Schema SQL Files](../../../../specs/03-Data-and-Storage/)
|
||||
- [Data Dictionary](../../../../specs/03-Data-and-Storage/03-01-data-dictionary.md)
|
||||
|
||||
> **Warning**: Attempting to use TypeORM migrations in this project violates ADR-009 and will be rejected in code review.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Use Transactions for Multi-Step Operations
|
||||
impact: HIGH
|
||||
impactDescription: Ensures data consistency in multi-step operations
|
||||
tags: database, transactions, typeorm, consistency
|
||||
---
|
||||
|
||||
## Use Transactions for Multi-Step Operations
|
||||
|
||||
When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios.
|
||||
|
||||
**Incorrect (multiple saves without transaction):**
|
||||
|
||||
```typescript
|
||||
// Multiple saves without transaction
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
|
||||
// If any step fails, data is inconsistent
|
||||
const order = await this.orderRepo.save({ userId, status: 'pending' });
|
||||
|
||||
for (const item of items) {
|
||||
await this.orderItemRepo.save({ orderId: order.id, ...item });
|
||||
await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity);
|
||||
}
|
||||
|
||||
await this.paymentService.charge(order.id);
|
||||
// If payment fails, order and inventory are already modified!
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use DataSource.transaction for automatic rollback):**
|
||||
|
||||
```typescript
|
||||
// Use DataSource.transaction() for automatic rollback
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// All operations use the same transactional manager
|
||||
const order = await manager.save(Order, { userId, status: 'pending' });
|
||||
|
||||
for (const item of items) {
|
||||
await manager.save(OrderItem, { orderId: order.id, ...item });
|
||||
await manager.decrement(Inventory, { productId: item.productId }, 'stock', item.quantity);
|
||||
}
|
||||
|
||||
// If this throws, everything rolls back
|
||||
await this.paymentService.chargeWithManager(manager, order.id);
|
||||
|
||||
return order;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// QueryRunner for manual transaction control
|
||||
@Injectable()
|
||||
export class TransferService {
|
||||
constructor(private dataSource: DataSource) {}
|
||||
|
||||
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
try {
|
||||
// Debit source account
|
||||
await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount);
|
||||
|
||||
// Verify sufficient funds
|
||||
const source = await queryRunner.manager.findOne(Account, {
|
||||
where: { id: fromId },
|
||||
});
|
||||
if (source.balance < 0) {
|
||||
throw new BadRequestException('Insufficient funds');
|
||||
}
|
||||
|
||||
// Credit destination account
|
||||
await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount);
|
||||
|
||||
// Log the transaction
|
||||
await queryRunner.manager.save(TransactionLog, {
|
||||
fromId,
|
||||
toId,
|
||||
amount,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Repository method with transaction support
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
constructor(
|
||||
@InjectRepository(User) private repo: Repository<User>,
|
||||
private dataSource: DataSource
|
||||
) {}
|
||||
|
||||
async createWithProfile(userData: CreateUserDto, profileData: CreateProfileDto): Promise<User> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const user = await manager.save(User, userData);
|
||||
await manager.save(Profile, { ...profileData, userId: user.id });
|
||||
return user;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [TypeORM Transactions](https://typeorm.io/transactions)
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
title: Implement Graceful Shutdown
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Proper shutdown handling ensures zero-downtime deployments
|
||||
tags: devops, graceful-shutdown, lifecycle, kubernetes
|
||||
---
|
||||
|
||||
## Implement Graceful Shutdown
|
||||
|
||||
Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments.
|
||||
|
||||
**Incorrect (ignoring shutdown signals):**
|
||||
|
||||
```typescript
|
||||
// Ignore shutdown signals
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(3000);
|
||||
// App crashes immediately on SIGTERM
|
||||
// In-flight requests fail
|
||||
// Database connections are abruptly closed
|
||||
}
|
||||
|
||||
// Long-running tasks without cancellation
|
||||
@Injectable()
|
||||
export class ProcessingService {
|
||||
async processLargeFile(file: File): Promise<void> {
|
||||
// No way to interrupt this during shutdown
|
||||
for (let i = 0; i < file.chunks.length; i++) {
|
||||
await this.processChunk(file.chunks[i]);
|
||||
// May run for minutes, blocking shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (enable shutdown hooks and handle cleanup):**
|
||||
|
||||
```typescript
|
||||
// Enable shutdown hooks in main.ts
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable shutdown hooks
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Optional: Add timeout for forced shutdown
|
||||
const server = await app.listen(3000);
|
||||
server.setTimeout(30000); // 30 second timeout
|
||||
|
||||
// Handle graceful shutdown
|
||||
const signals = ['SIGTERM', 'SIGINT'];
|
||||
signals.forEach((signal) => {
|
||||
process.on(signal, async () => {
|
||||
console.log(`Received ${signal}, starting graceful shutdown...`);
|
||||
|
||||
// Stop accepting new connections
|
||||
server.close(async () => {
|
||||
console.log('HTTP server closed');
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force exit after timeout
|
||||
setTimeout(() => {
|
||||
console.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 30000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Lifecycle hooks for cleanup
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnApplicationShutdown {
|
||||
private readonly connections: Connection[] = [];
|
||||
|
||||
async onApplicationShutdown(signal?: string): Promise<void> {
|
||||
console.log(`Database service shutting down on ${signal}`);
|
||||
|
||||
// Close all connections gracefully
|
||||
await Promise.all(this.connections.map((conn) => conn.close()));
|
||||
|
||||
console.log('All database connections closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Queue processor with graceful shutdown
|
||||
@Injectable()
|
||||
export class QueueService implements OnApplicationShutdown, OnModuleDestroy {
|
||||
private isShuttingDown = false;
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.isShuttingDown = true;
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
// Wait for current jobs to complete
|
||||
await this.queue.close();
|
||||
}
|
||||
|
||||
async processJob(job: Job): Promise<void> {
|
||||
if (this.isShuttingDown) {
|
||||
throw new Error('Service is shutting down');
|
||||
}
|
||||
await this.doWork(job);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket gateway cleanup
|
||||
@WebSocketGateway()
|
||||
export class EventsGateway implements OnApplicationShutdown {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
// Notify all connected clients
|
||||
this.server.emit('shutdown', { message: 'Server is shutting down' });
|
||||
|
||||
// Close all connections
|
||||
this.server.disconnectSockets();
|
||||
}
|
||||
}
|
||||
|
||||
// Health check integration
|
||||
@Injectable()
|
||||
export class ShutdownService {
|
||||
private isShuttingDown = false;
|
||||
|
||||
startShutdown(): void {
|
||||
this.isShuttingDown = true;
|
||||
}
|
||||
|
||||
isShutdown(): boolean {
|
||||
return this.isShuttingDown;
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private shutdownService: ShutdownService) {}
|
||||
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
readiness(): Promise<HealthCheckResult> {
|
||||
// Return 503 during shutdown - k8s stops sending traffic
|
||||
if (this.shutdownService.isShutdown()) {
|
||||
throw new ServiceUnavailableException('Shutting down');
|
||||
}
|
||||
|
||||
return this.health.check([() => this.db.pingCheck('database')]);
|
||||
}
|
||||
}
|
||||
|
||||
// Integrate with shutdown
|
||||
@Injectable()
|
||||
export class AppShutdownService implements OnApplicationShutdown {
|
||||
constructor(private shutdownService: ShutdownService) {}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
// Mark as unhealthy first
|
||||
this.shutdownService.startShutdown();
|
||||
|
||||
// Wait for k8s to update endpoints
|
||||
await this.sleep(5000);
|
||||
|
||||
// Then proceed with cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Request tracking for in-flight requests
|
||||
@Injectable()
|
||||
export class RequestTracker implements NestMiddleware, OnApplicationShutdown {
|
||||
private activeRequests = 0;
|
||||
private isShuttingDown = false;
|
||||
private shutdownPromise: Promise<void> | null = null;
|
||||
private resolveShutdown: (() => void) | null = null;
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
if (this.isShuttingDown) {
|
||||
res.status(503).send('Service Unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeRequests++;
|
||||
|
||||
res.on('finish', () => {
|
||||
this.activeRequests--;
|
||||
if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) {
|
||||
this.resolveShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
if (this.activeRequests > 0) {
|
||||
console.log(`Waiting for ${this.activeRequests} requests to complete`);
|
||||
this.shutdownPromise = new Promise((resolve) => {
|
||||
this.resolveShutdown = resolve;
|
||||
});
|
||||
|
||||
// Wait with timeout
|
||||
await Promise.race([this.shutdownPromise, new Promise((resolve) => setTimeout(resolve, 30000))]);
|
||||
}
|
||||
|
||||
console.log('All requests completed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events)
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Use ConfigModule for Environment Configuration
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Proper configuration prevents deployment failures
|
||||
tags: devops, configuration, environment, validation
|
||||
---
|
||||
|
||||
## Use ConfigModule for Environment Configuration
|
||||
|
||||
Use `@nestjs/config` for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety.
|
||||
|
||||
**Incorrect (accessing process.env directly):**
|
||||
|
||||
```typescript
|
||||
// Access process.env directly
|
||||
@Injectable()
|
||||
export class DatabaseService {
|
||||
constructor() {
|
||||
// No validation, can fail at runtime
|
||||
this.connection = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT), // NaN if missing
|
||||
password: process.env.DB_PASSWORD, // undefined if missing
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Scattered env access
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
sendEmail() {
|
||||
// Different services access env differently
|
||||
const apiKey = process.env.SENDGRID_API_KEY || 'default';
|
||||
// Typos go unnoticed: process.env.SENDGRID_API_KY
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use @nestjs/config with validation):**
|
||||
|
||||
```typescript
|
||||
// Setup validated configuration
|
||||
import { ConfigModule, ConfigService, registerAs } from '@nestjs/config';
|
||||
import * as Joi from 'joi';
|
||||
|
||||
// config/database.config.ts
|
||||
export const databaseConfig = registerAs('database', () => ({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT, 10),
|
||||
username: process.env.DB_USERNAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
}));
|
||||
|
||||
// config/app.config.ts
|
||||
export const appConfig = registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT, 10) || 3000,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
apiPrefix: process.env.API_PREFIX || 'api',
|
||||
}));
|
||||
|
||||
// config/validation.schema.ts
|
||||
export const validationSchema = Joi.object({
|
||||
NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
|
||||
PORT: Joi.number().default(3000),
|
||||
DB_HOST: Joi.string().required(),
|
||||
DB_PORT: Joi.number().default(5432),
|
||||
DB_USERNAME: Joi.string().required(),
|
||||
DB_PASSWORD: Joi.string().required(),
|
||||
DB_NAME: Joi.string().required(),
|
||||
JWT_SECRET: Joi.string().min(32).required(),
|
||||
REDIS_URL: Joi.string().uri().required(),
|
||||
});
|
||||
|
||||
// app.module.ts
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Available everywhere without importing
|
||||
load: [databaseConfig, appConfig],
|
||||
validationSchema,
|
||||
validationOptions: {
|
||||
abortEarly: true, // Stop on first error
|
||||
allowUnknown: true, // Allow other env vars
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: config.get('database.host'),
|
||||
port: config.get('database.port'),
|
||||
username: config.get('database.username'),
|
||||
password: config.get('database.password'),
|
||||
database: config.get('database.database'),
|
||||
autoLoadEntities: true,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Type-safe configuration access
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
environment: 'development' | 'production' | 'test';
|
||||
apiPrefix: string;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
// Type-safe access
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
getPort(): number {
|
||||
// Type-safe with generic
|
||||
return this.config.get<number>('app.port');
|
||||
}
|
||||
|
||||
getDatabaseConfig(): DatabaseConfig {
|
||||
return this.config.get<DatabaseConfig>('database');
|
||||
}
|
||||
}
|
||||
|
||||
// Inject namespaced config directly
|
||||
@Injectable()
|
||||
export class DatabaseService {
|
||||
constructor(
|
||||
@Inject(databaseConfig.KEY)
|
||||
private dbConfig: ConfigType<typeof databaseConfig>
|
||||
) {
|
||||
// Full type inference!
|
||||
const host = this.dbConfig.host; // string
|
||||
const port = this.dbConfig.port; // number
|
||||
}
|
||||
}
|
||||
|
||||
// Environment files support
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: [`.env.${process.env.NODE_ENV}.local`, `.env.${process.env.NODE_ENV}`, '.env.local', '.env'],
|
||||
});
|
||||
|
||||
// .env.development
|
||||
// DB_HOST=localhost
|
||||
// DB_PORT=5432
|
||||
|
||||
// .env.production
|
||||
// DB_HOST=prod-db.example.com
|
||||
// DB_PORT=5432
|
||||
```
|
||||
|
||||
Reference: [NestJS Configuration](https://docs.nestjs.com/techniques/configuration)
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
title: Use Structured Logging
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Structured logging enables effective debugging and monitoring
|
||||
tags: devops, logging, structured-logs, pino
|
||||
---
|
||||
|
||||
## Use Structured Logging
|
||||
|
||||
Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels.
|
||||
|
||||
**Incorrect (using console.log in production):**
|
||||
|
||||
```typescript
|
||||
// Use console.log in production
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async createUser(dto: CreateUserDto): Promise<User> {
|
||||
console.log('Creating user:', dto);
|
||||
// Not structured, no levels, lost in production logs
|
||||
|
||||
try {
|
||||
const user = await this.repo.save(dto);
|
||||
console.log('User created:', user.id);
|
||||
return user;
|
||||
} catch (error) {
|
||||
console.log('Error:', error); // Using log for errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log sensitive data
|
||||
console.log('Login attempt:', { email, password }); // SECURITY RISK!
|
||||
|
||||
// Inconsistent log format
|
||||
logger.log('User ' + userId + ' created at ' + new Date());
|
||||
// Hard to parse, no structure
|
||||
```
|
||||
|
||||
**Correct (use structured logging with context):**
|
||||
|
||||
```typescript
|
||||
// Configure logger in main.ts
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger:
|
||||
process.env.NODE_ENV === 'production' ? ['error', 'warn', 'log'] : ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
}
|
||||
|
||||
// Use NestJS Logger with context
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
async createUser(dto: CreateUserDto): Promise<User> {
|
||||
this.logger.log('Creating user', { email: dto.email });
|
||||
|
||||
try {
|
||||
const user = await this.repo.save(dto);
|
||||
this.logger.log('User created', { userId: user.id });
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create user', error.stack, {
|
||||
email: dto.email,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom logger for JSON output
|
||||
@Injectable()
|
||||
export class JsonLogger implements LoggerService {
|
||||
log(message: string, context?: object): void {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
...context,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
error(message: string, trace?: string, context?: object): void {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
level: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
trace,
|
||||
...context,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
warn(message: string, context?: object): void {
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
level: 'warn',
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
...context,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
debug(message: string, context?: object): void {
|
||||
console.debug(
|
||||
JSON.stringify({
|
||||
level: 'debug',
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
...context,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Request context logging with ClsModule
|
||||
import { ClsModule, ClsService } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: {
|
||||
mount: true,
|
||||
generateId: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Middleware to set request context
|
||||
@Injectable()
|
||||
export class RequestContextMiddleware implements NestMiddleware {
|
||||
constructor(private cls: ClsService) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
const requestId = req.headers['x-request-id'] || randomUUID();
|
||||
this.cls.set('requestId', requestId);
|
||||
this.cls.set('userId', req.user?.id);
|
||||
|
||||
res.setHeader('x-request-id', requestId);
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
// Logger that includes request context
|
||||
@Injectable()
|
||||
export class ContextLogger {
|
||||
constructor(private cls: ClsService) {}
|
||||
|
||||
log(message: string, data?: object): void {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
level: 'info',
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: this.cls.get('requestId'),
|
||||
userId: this.cls.get('userId'),
|
||||
message,
|
||||
...data,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
error(message: string, error: Error, data?: object): void {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
level: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: this.cls.get('requestId'),
|
||||
userId: this.cls.get('userId'),
|
||||
message,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
...data,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pino integration for high-performance logging
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined,
|
||||
redact: ['req.headers.authorization', 'req.body.password'],
|
||||
serializers: {
|
||||
req: (req) => ({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
query: req.query,
|
||||
}),
|
||||
res: (res) => ({
|
||||
statusCode: res.statusCode,
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Usage with Pino
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private logger: PinoLogger) {
|
||||
this.logger.setContext(UsersService.name);
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<User> {
|
||||
this.logger.info({ userId: id }, 'Finding user');
|
||||
// Pino uses first arg for data, second for message
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Logger](https://docs.nestjs.com/techniques/logger)
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Avoid Service Locator Anti-Pattern
|
||||
impact: HIGH
|
||||
impactDescription: Hides dependencies and breaks testability
|
||||
tags: dependency-injection, anti-patterns, testing
|
||||
---
|
||||
|
||||
## Avoid Service Locator Anti-Pattern
|
||||
|
||||
Avoid using `ModuleRef.get()` or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead.
|
||||
|
||||
**Incorrect (service locator anti-pattern):**
|
||||
|
||||
```typescript
|
||||
// Use ModuleRef to get dependencies dynamically
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private moduleRef: ModuleRef) {}
|
||||
|
||||
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
||||
// Dependencies are hidden - not visible in constructor
|
||||
const usersService = this.moduleRef.get(UsersService);
|
||||
const inventoryService = this.moduleRef.get(InventoryService);
|
||||
const paymentService = this.moduleRef.get(PaymentService);
|
||||
|
||||
const user = await usersService.findOne(dto.userId);
|
||||
// ... rest of logic
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton container
|
||||
class ServiceContainer {
|
||||
private static instance: ServiceContainer;
|
||||
private services = new Map<string, any>();
|
||||
|
||||
static getInstance(): ServiceContainer {
|
||||
if (!this.instance) {
|
||||
this.instance = new ServiceContainer();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
get<T>(key: string): T {
|
||||
return this.services.get(key);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (constructor injection with explicit dependencies):**
|
||||
|
||||
```typescript
|
||||
// Use constructor injection - dependencies are explicit
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
private inventoryService: InventoryService,
|
||||
private paymentService: PaymentService
|
||||
) {}
|
||||
|
||||
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
||||
const user = await this.usersService.findOne(dto.userId);
|
||||
const inventory = await this.inventoryService.check(dto.items);
|
||||
// Dependencies are clear and testable
|
||||
}
|
||||
}
|
||||
|
||||
// Easy to test with mocks
|
||||
describe('OrdersService', () => {
|
||||
let service: OrdersService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
OrdersService,
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{ provide: InventoryService, useValue: mockInventoryService },
|
||||
{ provide: PaymentService, useValue: mockPaymentService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(OrdersService);
|
||||
});
|
||||
});
|
||||
|
||||
// VALID: Factory pattern for dynamic instantiation
|
||||
@Injectable()
|
||||
export class HandlerFactory {
|
||||
constructor(private moduleRef: ModuleRef) {}
|
||||
|
||||
getHandler(type: string): Handler {
|
||||
switch (type) {
|
||||
case 'email':
|
||||
return this.moduleRef.get(EmailHandler);
|
||||
case 'sms':
|
||||
return this.moduleRef.get(SmsHandler);
|
||||
default:
|
||||
return this.moduleRef.get(DefaultHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Module Reference](https://docs.nestjs.com/fundamentals/module-ref)
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: Apply Interface Segregation Principle
|
||||
impact: HIGH
|
||||
impactDescription: Reduces coupling and improves testability by 30-50%
|
||||
tags: dependency-injection, interfaces, solid, isp
|
||||
---
|
||||
|
||||
## Apply Interface Segregation Principle
|
||||
|
||||
Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones.
|
||||
|
||||
**Incorrect (fat interface forcing unused dependencies):**
|
||||
|
||||
```typescript
|
||||
// Fat interface - forces all consumers to depend on everything
|
||||
interface NotificationService {
|
||||
sendEmail(to: string, subject: string, body: string): Promise<void>;
|
||||
sendSms(phone: string, message: string): Promise<void>;
|
||||
sendPush(userId: string, notification: PushPayload): Promise<void>;
|
||||
sendSlack(channel: string, message: string): Promise<void>;
|
||||
logNotification(type: string, payload: any): Promise<void>;
|
||||
getDeliveryStatus(id: string): Promise<DeliveryStatus>;
|
||||
retryFailed(id: string): Promise<void>;
|
||||
scheduleNotification(dto: ScheduleDto): Promise<string>;
|
||||
}
|
||||
|
||||
// Consumer only needs email, but must mock everything for tests
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private notifications: NotificationService // Depends on 8 methods, uses 1
|
||||
) {}
|
||||
|
||||
async confirmOrder(order: Order): Promise<void> {
|
||||
await this.notifications.sendEmail(
|
||||
order.customer.email,
|
||||
'Order Confirmed',
|
||||
`Your order ${order.id} has been confirmed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Testing is painful - must mock unused methods
|
||||
const mockNotificationService = {
|
||||
sendEmail: jest.fn(),
|
||||
sendSms: jest.fn(), // Never used, but required
|
||||
sendPush: jest.fn(), // Never used, but required
|
||||
sendSlack: jest.fn(), // Never used, but required
|
||||
logNotification: jest.fn(), // Never used, but required
|
||||
getDeliveryStatus: jest.fn(), // Never used, but required
|
||||
retryFailed: jest.fn(), // Never used, but required
|
||||
scheduleNotification: jest.fn(), // Never used, but required
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (segregated interfaces by capability):**
|
||||
|
||||
```typescript
|
||||
// Segregated interfaces - each focused on one capability
|
||||
interface EmailSender {
|
||||
sendEmail(to: string, subject: string, body: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface SmsSender {
|
||||
sendSms(phone: string, message: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface PushSender {
|
||||
sendPush(userId: string, notification: PushPayload): Promise<void>;
|
||||
}
|
||||
|
||||
interface NotificationLogger {
|
||||
logNotification(type: string, payload: any): Promise<void>;
|
||||
}
|
||||
|
||||
interface NotificationScheduler {
|
||||
scheduleNotification(dto: ScheduleDto): Promise<string>;
|
||||
}
|
||||
|
||||
// Implementation can implement multiple interfaces
|
||||
@Injectable()
|
||||
export class NotificationService implements EmailSender, SmsSender, PushSender {
|
||||
async sendEmail(to: string, subject: string, body: string): Promise<void> {
|
||||
// Email implementation
|
||||
}
|
||||
|
||||
async sendSms(phone: string, message: string): Promise<void> {
|
||||
// SMS implementation
|
||||
}
|
||||
|
||||
async sendPush(userId: string, notification: PushPayload): Promise<void> {
|
||||
// Push implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Or separate implementations
|
||||
@Injectable()
|
||||
export class SendGridEmailService implements EmailSender {
|
||||
async sendEmail(to: string, subject: string, body: string): Promise<void> {
|
||||
// SendGrid-specific implementation
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer depends only on what it needs
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
@Inject(EMAIL_SENDER) private emailSender: EmailSender // Minimal dependency
|
||||
) {}
|
||||
|
||||
async confirmOrder(order: Order): Promise<void> {
|
||||
await this.emailSender.sendEmail(
|
||||
order.customer.email,
|
||||
'Order Confirmed',
|
||||
`Your order ${order.id} has been confirmed.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Testing is simple - only mock what's used
|
||||
const mockEmailSender: EmailSender = {
|
||||
sendEmail: jest.fn(),
|
||||
};
|
||||
|
||||
// Module registration with tokens
|
||||
export const EMAIL_SENDER = Symbol('EMAIL_SENDER');
|
||||
export const SMS_SENDER = Symbol('SMS_SENDER');
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: EMAIL_SENDER, useClass: SendGridEmailService },
|
||||
{ provide: SMS_SENDER, useClass: TwilioSmsService },
|
||||
],
|
||||
exports: [EMAIL_SENDER, SMS_SENDER],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
```
|
||||
|
||||
**Combining interfaces when needed:**
|
||||
|
||||
```typescript
|
||||
// Sometimes a consumer legitimately needs multiple capabilities
|
||||
interface EmailAndSmsSender extends EmailSender, SmsSender {}
|
||||
|
||||
// Or use intersection types
|
||||
type MultiChannelSender = EmailSender & SmsSender & PushSender;
|
||||
|
||||
// Consumer that genuinely needs multiple channels
|
||||
@Injectable()
|
||||
export class AlertService {
|
||||
constructor(
|
||||
@Inject(MULTI_CHANNEL_SENDER)
|
||||
private sender: EmailSender & SmsSender
|
||||
) {}
|
||||
|
||||
async sendCriticalAlert(user: User, message: string): Promise<void> {
|
||||
await Promise.all([
|
||||
this.sender.sendEmail(user.email, 'Critical Alert', message),
|
||||
this.sender.sendSms(user.phone, message),
|
||||
]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Interface Segregation Principle](https://en.wikipedia.org/wiki/Interface_segregation_principle)
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: Honor Liskov Substitution Principle
|
||||
impact: HIGH
|
||||
impactDescription: Ensures implementations are truly interchangeable without breaking callers
|
||||
tags: dependency-injection, inheritance, solid, lsp
|
||||
---
|
||||
|
||||
## Honor Liskov Substitution Principle
|
||||
|
||||
Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations.
|
||||
|
||||
**Incorrect (implementation violates the contract):**
|
||||
|
||||
```typescript
|
||||
// Base interface with clear contract
|
||||
interface PaymentGateway {
|
||||
/**
|
||||
* Charges the specified amount.
|
||||
* @returns PaymentResult on success
|
||||
* @throws PaymentFailedException on payment failure
|
||||
*/
|
||||
charge(amount: number, currency: string): Promise<PaymentResult>;
|
||||
}
|
||||
|
||||
// Production implementation - follows the contract
|
||||
@Injectable()
|
||||
export class StripeService implements PaymentGateway {
|
||||
async charge(amount: number, currency: string): Promise<PaymentResult> {
|
||||
const response = await this.stripe.charges.create({ amount, currency });
|
||||
return { success: true, transactionId: response.id, amount };
|
||||
}
|
||||
}
|
||||
|
||||
// Mock that violates LSP - different behavior!
|
||||
@Injectable()
|
||||
export class MockPaymentService implements PaymentGateway {
|
||||
async charge(amount: number, currency: string): Promise<PaymentResult> {
|
||||
// VIOLATION 1: Throws for valid input (contract says return PaymentResult)
|
||||
if (amount > 1000) {
|
||||
throw new Error('Mock does not support large amounts');
|
||||
}
|
||||
|
||||
// VIOLATION 2: Returns null instead of PaymentResult
|
||||
if (currency !== 'USD') {
|
||||
return null as any; // Real service would convert or reject properly
|
||||
}
|
||||
|
||||
// VIOLATION 3: Missing required field
|
||||
return { success: true } as PaymentResult; // Missing transactionId!
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer trusts the contract
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
|
||||
|
||||
async checkout(order: Order): Promise<void> {
|
||||
const result = await this.payment.charge(order.total, order.currency);
|
||||
// These fail with MockPaymentService:
|
||||
await this.saveTransaction(result.transactionId); // undefined!
|
||||
await this.sendReceipt(result); // might be null!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (implementations honor the contract):**
|
||||
|
||||
```typescript
|
||||
// Well-defined interface with documented behavior
|
||||
interface PaymentGateway {
|
||||
/**
|
||||
* Charges the specified amount.
|
||||
* @param amount - Amount in smallest currency unit (cents)
|
||||
* @param currency - ISO 4217 currency code
|
||||
* @returns PaymentResult with transactionId, success status, and amount
|
||||
* @throws PaymentFailedException if charge is declined
|
||||
* @throws InvalidCurrencyException if currency is not supported
|
||||
*/
|
||||
charge(amount: number, currency: string): Promise<PaymentResult>;
|
||||
|
||||
/**
|
||||
* Refunds a previous charge.
|
||||
* @throws TransactionNotFoundException if transactionId is invalid
|
||||
*/
|
||||
refund(transactionId: string, amount?: number): Promise<RefundResult>;
|
||||
}
|
||||
|
||||
// Production implementation
|
||||
@Injectable()
|
||||
export class StripeService implements PaymentGateway {
|
||||
async charge(amount: number, currency: string): Promise<PaymentResult> {
|
||||
try {
|
||||
const response = await this.stripe.charges.create({ amount, currency });
|
||||
return {
|
||||
success: true,
|
||||
transactionId: response.id,
|
||||
amount: response.amount,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.type === 'card_error') {
|
||||
throw new PaymentFailedException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
|
||||
// Implementation...
|
||||
}
|
||||
}
|
||||
|
||||
// Mock that honors LSP - same contract, same behavior shape
|
||||
@Injectable()
|
||||
export class MockPaymentService implements PaymentGateway {
|
||||
private transactions = new Map<string, PaymentResult>();
|
||||
|
||||
async charge(amount: number, currency: string): Promise<PaymentResult> {
|
||||
// Honor the contract: validate currency like real service would
|
||||
if (!['USD', 'EUR', 'GBP'].includes(currency)) {
|
||||
throw new InvalidCurrencyException(`Unsupported currency: ${currency}`);
|
||||
}
|
||||
|
||||
// Simulate decline for specific test scenarios
|
||||
if (amount === 99999) {
|
||||
throw new PaymentFailedException('Card declined (test scenario)');
|
||||
}
|
||||
|
||||
// Return same shape as production
|
||||
const result: PaymentResult = {
|
||||
success: true,
|
||||
transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`,
|
||||
amount,
|
||||
};
|
||||
|
||||
this.transactions.set(result.transactionId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
|
||||
// Honor the contract: throw if transaction not found
|
||||
if (!this.transactions.has(transactionId)) {
|
||||
throw new TransactionNotFoundException(transactionId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
refundId: `refund_${transactionId}`,
|
||||
amount: amount ?? this.transactions.get(transactionId)!.amount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer can swap implementations safely
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
|
||||
|
||||
async checkout(order: Order): Promise<Order> {
|
||||
try {
|
||||
const result = await this.payment.charge(order.total, order.currency);
|
||||
// Works with both StripeService and MockPaymentService
|
||||
order.transactionId = result.transactionId;
|
||||
order.status = 'paid';
|
||||
return order;
|
||||
} catch (error) {
|
||||
if (error instanceof PaymentFailedException) {
|
||||
order.status = 'payment_failed';
|
||||
return order;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Testing LSP compliance:**
|
||||
|
||||
```typescript
|
||||
// Shared test suite that any implementation must pass
|
||||
function testPaymentGatewayContract(createGateway: () => PaymentGateway) {
|
||||
describe('PaymentGateway contract', () => {
|
||||
let gateway: PaymentGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
gateway = createGateway();
|
||||
});
|
||||
|
||||
it('returns PaymentResult with all required fields', async () => {
|
||||
const result = await gateway.charge(1000, 'USD');
|
||||
expect(result).toHaveProperty('success');
|
||||
expect(result).toHaveProperty('transactionId');
|
||||
expect(result).toHaveProperty('amount');
|
||||
expect(typeof result.transactionId).toBe('string');
|
||||
});
|
||||
|
||||
it('throws InvalidCurrencyException for unsupported currency', async () => {
|
||||
await expect(gateway.charge(1000, 'INVALID')).rejects.toThrow(InvalidCurrencyException);
|
||||
});
|
||||
|
||||
it('throws TransactionNotFoundException for invalid refund', async () => {
|
||||
await expect(gateway.refund('nonexistent')).rejects.toThrow(TransactionNotFoundException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Run against all implementations
|
||||
describe('StripeService', () => {
|
||||
testPaymentGatewayContract(() => new StripeService(mockStripeClient));
|
||||
});
|
||||
|
||||
describe('MockPaymentService', () => {
|
||||
testPaymentGatewayContract(() => new MockPaymentService());
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [Liskov Substitution Principle](https://en.wikipedia.org/wiki/Liskov_substitution_principle)
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Prefer Constructor Injection
|
||||
impact: CRITICAL
|
||||
impactDescription: Required for proper DI and testing
|
||||
tags: dependency-injection, constructor, testing
|
||||
---
|
||||
|
||||
## Prefer Constructor Injection
|
||||
|
||||
Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support.
|
||||
|
||||
**Incorrect (property injection with hidden dependencies):**
|
||||
|
||||
```typescript
|
||||
// Property injection - avoid unless necessary
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@Inject()
|
||||
private userRepo: UserRepository; // Hidden dependency
|
||||
|
||||
@Inject('CONFIG')
|
||||
private config: ConfigType; // Also hidden
|
||||
|
||||
async findAll() {
|
||||
return this.userRepo.find();
|
||||
}
|
||||
}
|
||||
|
||||
// Problems:
|
||||
// 1. Dependencies not visible in constructor
|
||||
// 2. Service can be instantiated without dependencies in tests
|
||||
// 3. TypeScript can't enforce dependency types at instantiation
|
||||
```
|
||||
|
||||
**Correct (constructor injection with explicit dependencies):**
|
||||
|
||||
```typescript
|
||||
// Constructor injection - explicit and testable
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly userRepo: UserRepository,
|
||||
@Inject('CONFIG') private readonly config: ConfigType
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
return this.userRepo.find();
|
||||
}
|
||||
}
|
||||
|
||||
// Testing is straightforward
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let mockRepo: jest.Mocked<UserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
} as any;
|
||||
|
||||
service = new UsersService(mockRepo, { dbUrl: 'test' });
|
||||
});
|
||||
|
||||
it('should find all users', async () => {
|
||||
mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]);
|
||||
const result = await service.findAll();
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Only use property injection for optional dependencies
|
||||
@Injectable()
|
||||
export class LoggingService {
|
||||
@Optional()
|
||||
@Inject('ANALYTICS')
|
||||
private analytics?: AnalyticsService;
|
||||
|
||||
log(message: string) {
|
||||
console.log(message);
|
||||
this.analytics?.track('log', message); // Optional enhancement
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Providers](https://docs.nestjs.com/providers)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: Understand Provider Scopes
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevents data leaks and performance issues
|
||||
tags: dependency-injection, scopes, request-context
|
||||
---
|
||||
|
||||
## Understand Provider Scopes
|
||||
|
||||
NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing.
|
||||
|
||||
**Incorrect (wrong scope usage):**
|
||||
|
||||
```typescript
|
||||
// Request-scoped when not needed (performance hit)
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class UsersService {
|
||||
// This creates a new instance for EVERY request
|
||||
// All dependencies also become request-scoped
|
||||
async findAll() {
|
||||
return this.userRepo.find();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton with mutable request state
|
||||
@Injectable() // Default: singleton
|
||||
export class RequestContextService {
|
||||
private userId: string; // DANGER: Shared across all requests!
|
||||
|
||||
setUser(userId: string) {
|
||||
this.userId = userId; // Overwrites for all concurrent requests
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.userId; // Returns wrong user!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (appropriate scope for each use case):**
|
||||
|
||||
```typescript
|
||||
// Singleton for stateless services (default, most common)
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly userRepo: UserRepository) {}
|
||||
|
||||
async findById(id: string): Promise<User> {
|
||||
return this.userRepo.findOne({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
// Request-scoped ONLY when you need request context
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class RequestContextService {
|
||||
private userId: string;
|
||||
|
||||
setUser(userId: string) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
getUser(): string {
|
||||
return this.userId;
|
||||
}
|
||||
}
|
||||
|
||||
// Better: Use NestJS built-in request context
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class AuditService {
|
||||
constructor(@Inject(REQUEST) private request: Request) {}
|
||||
|
||||
log(action: string) {
|
||||
console.log(`User ${this.request.user?.id} performed ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Best: Use ClsModule for async context (no scope bubble-up)
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
@Injectable() // Stays singleton!
|
||||
export class AuditService {
|
||||
constructor(private cls: ClsService) {}
|
||||
|
||||
log(action: string) {
|
||||
const userId = this.cls.get('userId');
|
||||
console.log(`User ${userId} performed ${action}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Injection Scopes](https://docs.nestjs.com/fundamentals/injection-scopes)
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: Use Injection Tokens for Interfaces
|
||||
impact: HIGH
|
||||
impactDescription: Enables interface-based DI at runtime
|
||||
tags: dependency-injection, tokens, interfaces
|
||||
---
|
||||
|
||||
## Use Injection Tokens for Interfaces
|
||||
|
||||
TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments.
|
||||
|
||||
**Incorrect (interface can't be used as token):**
|
||||
|
||||
```typescript
|
||||
// Interface can't be used as injection token
|
||||
interface PaymentGateway {
|
||||
charge(amount: number): Promise<PaymentResult>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StripeService implements PaymentGateway {
|
||||
charge(amount: number) {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
// This WON'T work - PaymentGateway doesn't exist at runtime
|
||||
constructor(private payment: PaymentGateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (symbol tokens or abstract classes):**
|
||||
|
||||
```typescript
|
||||
// Option 1: String/Symbol tokens (most flexible)
|
||||
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
|
||||
|
||||
export interface PaymentGateway {
|
||||
charge(amount: number): Promise<PaymentResult>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StripeService implements PaymentGateway {
|
||||
async charge(amount: number): Promise<PaymentResult> {
|
||||
// Stripe implementation
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MockPaymentService implements PaymentGateway {
|
||||
async charge(amount: number): Promise<PaymentResult> {
|
||||
return { success: true, id: 'mock-id' };
|
||||
}
|
||||
}
|
||||
|
||||
// Module registration
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: PAYMENT_GATEWAY,
|
||||
useClass: process.env.NODE_ENV === 'test' ? MockPaymentService : StripeService,
|
||||
},
|
||||
],
|
||||
exports: [PAYMENT_GATEWAY],
|
||||
})
|
||||
export class PaymentModule {}
|
||||
|
||||
// Injection
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
|
||||
|
||||
async createOrder(dto: CreateOrderDto) {
|
||||
await this.payment.charge(dto.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Option 2: Abstract class (carries runtime type info)
|
||||
export abstract class PaymentGateway {
|
||||
abstract charge(amount: number): Promise<PaymentResult>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StripeService extends PaymentGateway {
|
||||
async charge(amount: number): Promise<PaymentResult> {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
|
||||
// No @Inject needed with abstract class
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private payment: PaymentGateway) {}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Custom Providers](https://docs.nestjs.com/fundamentals/custom-providers)
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Handle Async Errors Properly
|
||||
impact: HIGH
|
||||
impactDescription: Prevents process crashes from unhandled rejections
|
||||
tags: error-handling, async, promises
|
||||
---
|
||||
|
||||
## Handle Async Errors Properly
|
||||
|
||||
NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net.
|
||||
|
||||
**Incorrect (fire-and-forget without error handling):**
|
||||
|
||||
```typescript
|
||||
// Fire-and-forget without error handling
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async createUser(dto: CreateUserDto): Promise<User> {
|
||||
const user = await this.repo.save(dto);
|
||||
|
||||
// Fire and forget - if this fails, error is unhandled!
|
||||
this.emailService.sendWelcome(user.email);
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Unhandled promise in event handler
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
@OnEvent('order.created')
|
||||
handleOrderCreated(event: OrderCreatedEvent) {
|
||||
// This returns a promise but it's not awaited!
|
||||
this.processOrder(event);
|
||||
// Errors will crash the process
|
||||
}
|
||||
|
||||
private async processOrder(event: OrderCreatedEvent): Promise<void> {
|
||||
await this.inventoryService.reserve(event.items);
|
||||
await this.notificationService.send(event.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Missing try-catch in scheduled tasks
|
||||
@Cron('0 0 * * *')
|
||||
async dailyCleanup(): Promise<void> {
|
||||
await this.cleanupService.run();
|
||||
// If this throws, no error handling
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (explicit async error handling):**
|
||||
|
||||
```typescript
|
||||
// Handle fire-and-forget with explicit catch
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
async createUser(dto: CreateUserDto): Promise<User> {
|
||||
const user = await this.repo.save(dto);
|
||||
|
||||
// Explicitly catch and log errors
|
||||
this.emailService.sendWelcome(user.email).catch((error) => {
|
||||
this.logger.error('Failed to send welcome email', error.stack);
|
||||
// Optionally queue for retry
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Properly handle async event handlers
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
private readonly logger = new Logger(OrdersService.name);
|
||||
|
||||
@OnEvent('order.created')
|
||||
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
|
||||
try {
|
||||
await this.processOrder(event);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process order', { event, error });
|
||||
// Don't rethrow - would crash the process
|
||||
await this.deadLetterQueue.add('order.created', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safe scheduled tasks
|
||||
@Injectable()
|
||||
export class CleanupService {
|
||||
private readonly logger = new Logger(CleanupService.name);
|
||||
|
||||
@Cron('0 0 * * *')
|
||||
async dailyCleanup(): Promise<void> {
|
||||
try {
|
||||
await this.cleanupService.run();
|
||||
this.logger.log('Daily cleanup completed');
|
||||
} catch (error) {
|
||||
this.logger.error('Daily cleanup failed', error.stack);
|
||||
// Alert or retry logic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global unhandled rejection handler in main.ts
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught Exception:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Node.js Unhandled Rejections](https://nodejs.org/api/process.html#event-unhandledrejection)
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: Throw HTTP Exceptions from Services
|
||||
impact: HIGH
|
||||
impactDescription: Keeps controllers thin and simplifies error handling
|
||||
tags: error-handling, exceptions, services
|
||||
---
|
||||
|
||||
## Throw HTTP Exceptions from Services
|
||||
|
||||
It's acceptable (and often preferable) to throw `HttpException` subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes.
|
||||
|
||||
**Incorrect (return error objects instead of throwing):**
|
||||
|
||||
```typescript
|
||||
// Return error objects instead of throwing
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async findById(id: string): Promise<{ user?: User; error?: string }> {
|
||||
const user = await this.repo.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
return { error: 'User not found' }; // Controller must check this
|
||||
}
|
||||
return { user };
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const result = await this.usersService.findById(id);
|
||||
if (result.error) {
|
||||
throw new NotFoundException(result.error);
|
||||
}
|
||||
return result.user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (throw exceptions directly from service):**
|
||||
|
||||
```typescript
|
||||
// Throw exceptions directly from service
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly repo: UserRepository) {}
|
||||
|
||||
async findById(id: string): Promise<User> {
|
||||
const user = await this.repo.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User #${id} not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
const existing = await this.repo.findOne({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
return this.repo.save(dto);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateUserDto): Promise<User> {
|
||||
const user = await this.findById(id); // Throws if not found
|
||||
Object.assign(user, dto);
|
||||
return this.repo.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
// Controller stays thin
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string): Promise<User> {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateUserDto): Promise<User> {
|
||||
return this.usersService.create(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// For layer-agnostic services, use domain exceptions
|
||||
export class EntityNotFoundException extends Error {
|
||||
constructor(
|
||||
public readonly entity: string,
|
||||
public readonly id: string
|
||||
) {
|
||||
super(`${entity} with ID "${id}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// Map to HTTP in exception filter
|
||||
@Catch(EntityNotFoundException)
|
||||
export class EntityNotFoundFilter implements ExceptionFilter {
|
||||
catch(exception: EntityNotFoundException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
response.status(404).json({
|
||||
statusCode: 404,
|
||||
message: exception.message,
|
||||
entity: exception.entity,
|
||||
id: exception.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters)
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Use Exception Filters for Error Handling
|
||||
impact: HIGH
|
||||
impactDescription: Consistent, centralized error handling
|
||||
tags: error-handling, exception-filters, consistency
|
||||
---
|
||||
|
||||
## Use Exception Filters for Error Handling
|
||||
|
||||
Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions.
|
||||
|
||||
**Incorrect (manual error handling in controllers):**
|
||||
|
||||
```typescript
|
||||
// Manual error handling in controllers
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string, @Res() res: Response) {
|
||||
try {
|
||||
const user = await this.usersService.findById(id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
statusCode: 404,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
return res.json(user);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.status(500).json({
|
||||
statusCode: 500,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (exception filters with consistent handling):**
|
||||
|
||||
```typescript
|
||||
// Use built-in and custom exceptions
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
const user = await this.usersService.findById(id);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User #${id} not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom domain exception
|
||||
export class UserNotFoundException extends NotFoundException {
|
||||
constructor(userId: string) {
|
||||
super({
|
||||
statusCode: 404,
|
||||
error: 'Not Found',
|
||||
message: `User with ID "${userId}" not found`,
|
||||
code: 'USER_NOT_FOUND',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Custom exception filter for domain errors
|
||||
@Catch(DomainException)
|
||||
export class DomainExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: DomainException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status = exception.getStatus?.() || 400;
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
code: exception.code,
|
||||
message: exception.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Global exception filter for unhandled errors
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const message = exception instanceof HttpException ? exception.message : 'Internal server error';
|
||||
|
||||
this.logger.error(`${request.method} ${request.url}`, exception instanceof Error ? exception.stack : exception);
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally in main.ts
|
||||
app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger)), new DomainExceptionFilter());
|
||||
|
||||
// Or via module
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: AllExceptionsFilter,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
Reference: [NestJS Exception Filters](https://docs.nestjs.com/exception-filters)
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
title: AI Integration Boundary (ADR-018 / ADR-020)
|
||||
impact: CRITICAL
|
||||
impactDescription: AI runs on Admin Desktop only; AI → DMS API → DB (never direct); human-in-the-loop validation mandatory; full audit trail.
|
||||
tags: ai, ollama, boundary, adr-018, adr-020, privacy, audit
|
||||
---
|
||||
|
||||
## AI Integration Boundary
|
||||
|
||||
LCBP3 uses **on-premises AI only** (Ollama on Admin Desktop) with strict isolation from data layers.
|
||||
|
||||
---
|
||||
|
||||
## The Boundary
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ User Browser (Next.js) │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
│ (authenticated HTTPS)
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ DMS API (NestJS) ◀── enforces CASL, validation, audit │
|
||||
│ ├─ AiGateway (proxies to Ollama) │
|
||||
│ └─ DB + Storage (Elasticsearch, MariaDB, File System) │
|
||||
└─────────────────────────┬──────────────────────────────────┘
|
||||
│ (HTTP → Admin Desktop, internal)
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ Admin Desktop (Desk-5439) │
|
||||
│ ├─ Ollama (Gemma 4) │
|
||||
│ ├─ PaddleOCR (Thai + English) │
|
||||
│ └─ n8n orchestration │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**❗ Admin Desktop has NO network access to MariaDB, no SMB to storage, no shared secrets.** It receives base64-encoded file bytes over HTTPS and returns extracted text + suggestions.
|
||||
|
||||
---
|
||||
|
||||
## Required Patterns
|
||||
|
||||
### 1. AiGateway Module (backend)
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AiGateway, AiAuditLogger],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
async extractMetadata(fileId: number, user: User): Promise<ExtractedMetadata> {
|
||||
// 1. Authorize (CASL: user can read this file)
|
||||
await this.ability.ensureCan(user, 'read', File, fileId);
|
||||
|
||||
// 2. Load file (DMS API, inside the boundary)
|
||||
const fileBytes = await this.storageService.read(fileId);
|
||||
|
||||
// 3. Call Admin Desktop AI over HTTP
|
||||
const raw = await this.aiGateway.extract(fileBytes);
|
||||
|
||||
// 4. Validate AI output schema (Zod)
|
||||
const parsed = ExtractedMetadataSchema.parse(raw);
|
||||
|
||||
// 5. Audit log (who, what, when, model, confidence)
|
||||
await this.auditLogger.log({
|
||||
userId: user.id,
|
||||
action: 'ai.extract_metadata',
|
||||
fileId,
|
||||
model: raw.model,
|
||||
confidence: parsed.confidence,
|
||||
});
|
||||
|
||||
// 6. Return — frontend MUST render for human confirmation
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Human-in-the-Loop
|
||||
|
||||
AI output is **never persisted directly**. Users must confirm via `DocumentReviewForm`:
|
||||
|
||||
```tsx
|
||||
<DocumentReviewForm
|
||||
document={doc}
|
||||
aiSuggestions={suggestions}
|
||||
onConfirm={(reviewed) => saveMetadata(reviewed)} // user edits applied
|
||||
/>
|
||||
```
|
||||
|
||||
The `user_confirmed_at` timestamp and diff (AI suggestion → final value) are stored in the audit log.
|
||||
|
||||
### 3. Rate Limiting
|
||||
|
||||
```typescript
|
||||
@Post('ai/extract')
|
||||
@UseGuards(JwtAuthGuard, CaslAbilityGuard, ThrottlerGuard)
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } }) // 10 req/min/user
|
||||
async extract(@Body() dto: ExtractDto) { /* ... */ }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Forbidden
|
||||
|
||||
```typescript
|
||||
// ❌ AI container connecting to DB
|
||||
// docker-compose.yml inside ai-service:
|
||||
// environment:
|
||||
// DATABASE_URL: mysql://... ← NEVER
|
||||
|
||||
// ❌ AI SDK calling cloud API
|
||||
import OpenAI from 'openai'; // ❌ No cloud AI SDKs in production code
|
||||
const client = new OpenAI({ apiKey: ... });
|
||||
|
||||
// ❌ Persisting AI output without human confirm
|
||||
async extractAndSave(fileId: number) {
|
||||
const metadata = await this.ai.extract(fileId);
|
||||
await this.repo.save({ fileId, ...metadata }); // ❌ skips human review
|
||||
}
|
||||
|
||||
// ❌ Skipping audit log
|
||||
const result = await this.aiGateway.extract(bytes); // no logging
|
||||
return result;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Log Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE ai_audit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
public_id UUID UNIQUE NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
action VARCHAR(64) NOT NULL, -- 'ai.extract_metadata', 'ai.classify', etc.
|
||||
file_id INT,
|
||||
model VARCHAR(64), -- 'gemma-4:7b', 'paddleocr-v3'
|
||||
confidence DECIMAL(4,3),
|
||||
input_hash CHAR(64), -- SHA-256 of input for replay detection
|
||||
output_summary JSON,
|
||||
human_confirmed_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_created (user_id, created_at),
|
||||
INDEX idx_file (file_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-018 AI Boundary](../../../../specs/06-Decision-Records/ADR-018-ai-boundary.md)
|
||||
- [ADR-020 AI Intelligence Integration](../../../../specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md)
|
||||
- [ADR-017 Ollama Data Migration](../../../../specs/06-Decision-Records/ADR-017-ollama-data-migration.md)
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: Workflow Engine + Document Numbering + Workflow Context (ADR-001 / 002 / 021)
|
||||
impact: CRITICAL
|
||||
impactDescription: DSL-based state machine; double-lock numbering; integrated workflow context exposed to clients.
|
||||
tags: workflow, numbering, redlock, version-column, adr-001, adr-002, adr-021
|
||||
---
|
||||
|
||||
## Workflow Engine + Numbering + Context
|
||||
|
||||
LCBP3 uses a **unified workflow engine** (DSL-based state machine) across RFA, Transmittal, Correspondence, Circulation, and Shop Drawing. Every state transition goes through the same engine — no per-type routing tables.
|
||||
|
||||
---
|
||||
|
||||
## ADR-001: Unified Workflow Engine
|
||||
|
||||
### State Transition Pattern
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class WorkflowEngine {
|
||||
async transition(
|
||||
instanceId: string,
|
||||
action: WorkflowAction,
|
||||
actor: User,
|
||||
context?: WorkflowContext,
|
||||
): Promise<WorkflowInstance> {
|
||||
// 1. Load current state from DB (never trust client-provided state)
|
||||
const instance = await this.repo.findOneByPublicId(instanceId);
|
||||
if (!instance) throw new NotFoundException();
|
||||
|
||||
// 2. Validate transition against DSL
|
||||
const dsl = await this.dslService.load(instance.workflowTypeId);
|
||||
const nextState = dsl.resolve(instance.currentState, action);
|
||||
if (!nextState) {
|
||||
throw new BusinessException(
|
||||
`Action ${action} not allowed from state ${instance.currentState}`,
|
||||
'ไม่สามารถดำเนินการนี้ได้ในสถานะปัจจุบัน',
|
||||
'กรุณาตรวจสอบขั้นตอนการอนุมัติ',
|
||||
'WF_INVALID_TRANSITION',
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Apply transition atomically (optimistic lock via @VersionColumn)
|
||||
instance.currentState = nextState;
|
||||
await this.repo.save(instance); // throws OptimisticLockVersionMismatchError on race
|
||||
|
||||
// 4. Emit event for listeners (notifications via BullMQ — ADR-008)
|
||||
this.eventBus.publish(new WorkflowTransitionedEvent(instance, action, actor));
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Anti-Patterns
|
||||
|
||||
- ❌ Hard-coded `switch (state)` in controllers/services
|
||||
- ❌ Trusting `currentState` from request body
|
||||
- ❌ Creating separate routing tables per document type
|
||||
|
||||
---
|
||||
|
||||
## ADR-002: Document Numbering (Double-Lock)
|
||||
|
||||
Concurrent requests for a new document number **must** use both:
|
||||
|
||||
1. **Redis Redlock** — distributed lock across app instances
|
||||
2. **TypeORM `@VersionColumn`** — optimistic lock on counter row
|
||||
|
||||
### Counter Entity
|
||||
|
||||
```typescript
|
||||
@Entity('document_number_counters')
|
||||
@Unique(['projectId', 'documentTypeId'])
|
||||
export class DocumentNumberCounter extends UuidBaseEntity {
|
||||
@Column({ name: 'project_id' })
|
||||
projectId: number;
|
||||
|
||||
@Column({ name: 'document_type_id' })
|
||||
documentTypeId: number;
|
||||
|
||||
@Column({ name: 'last_number', default: 0 })
|
||||
lastNumber: number;
|
||||
|
||||
@VersionColumn()
|
||||
version: number; // ❗ Optimistic lock — do not rename, do not remove
|
||||
}
|
||||
```
|
||||
|
||||
### Service Pattern
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class DocumentNumberingService {
|
||||
constructor(
|
||||
@InjectRepository(DocumentNumberCounter)
|
||||
private counterRepo: Repository<DocumentNumberCounter>,
|
||||
private redlock: RedlockService,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async generateNext(ctx: NumberingContext): Promise<string> {
|
||||
const lockKey = `doc_num:${ctx.projectId}:${ctx.documentTypeId}`;
|
||||
|
||||
// Distributed lock — 3s TTL, up to 5 retries
|
||||
const lock = await this.redlock.acquire([lockKey], 3000);
|
||||
|
||||
try {
|
||||
// Optimistic lock via @VersionColumn
|
||||
const counter = await this.counterRepo.findOne({
|
||||
where: { projectId: ctx.projectId, documentTypeId: ctx.documentTypeId },
|
||||
});
|
||||
|
||||
if (!counter) {
|
||||
throw new NotFoundException('Counter not initialized for this project/type');
|
||||
}
|
||||
|
||||
counter.lastNumber += 1;
|
||||
await this.counterRepo.save(counter); // may throw OptimisticLockVersionMismatchError
|
||||
|
||||
return this.formatNumber(ctx, counter.lastNumber);
|
||||
} catch (err) {
|
||||
if (err instanceof OptimisticLockVersionMismatchError) {
|
||||
this.logger.warn(`Numbering race detected for ${lockKey}, retrying`);
|
||||
// Let caller retry via BullMQ retry policy
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
private formatNumber(ctx: NumberingContext, seq: number): string {
|
||||
// e.g. "LCBP3-RFA-0042"
|
||||
return `${ctx.projectCode}-${ctx.typeCode}-${String(seq).padStart(4, '0')}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Anti-Patterns
|
||||
|
||||
- ❌ App-side counter only (`let counter = 0; counter++`)
|
||||
- ❌ Using `findOne` + `update` without `@VersionColumn`
|
||||
- ❌ Using only Redis lock without DB optimistic lock (race if Redis fails)
|
||||
|
||||
---
|
||||
|
||||
## ADR-021: Integrated Workflow Context
|
||||
|
||||
Every workflow-aware API response **must** expose:
|
||||
|
||||
```typescript
|
||||
export class WorkflowEnvelope<T> {
|
||||
data: T;
|
||||
|
||||
workflow: {
|
||||
instancePublicId: string;
|
||||
currentState: string; // e.g. 'pending_review'
|
||||
availableActions: string[]; // e.g. ['approve', 'reject', 'request-revision']
|
||||
canEdit: boolean; // computed from CASL + current state
|
||||
lastTransitionAt: string; // ISO 8601
|
||||
};
|
||||
|
||||
stepAttachments?: Array<{ // files produced by the current/previous step
|
||||
publicId: string;
|
||||
fileName: string;
|
||||
stepCode: string;
|
||||
downloadUrl: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
Frontend uses `workflow.availableActions` to render buttons — no client-side state machine logic.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-001 Unified Workflow Engine](../../../../specs/06-Decision-Records/ADR-001-unified-workflow-engine.md)
|
||||
- [ADR-002 Document Numbering Strategy](../../../../specs/06-Decision-Records/ADR-002-document-numbering-strategy.md)
|
||||
- [ADR-021 Workflow Context](../../../../specs/06-Decision-Records/ADR-021-workflow-context.md)
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Implement Health Checks for Microservices
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Health checks enable orchestrators to manage service lifecycle
|
||||
tags: microservices, health-checks, terminus, kubernetes
|
||||
---
|
||||
|
||||
## Implement Health Checks for Microservices
|
||||
|
||||
Implement liveness and readiness probes using `@nestjs/terminus`. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly.
|
||||
|
||||
**Incorrect (simple ping that doesn't check dependencies):**
|
||||
|
||||
```typescript
|
||||
// Simple ping that doesn't check dependencies
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check(): string {
|
||||
return 'OK'; // Service might be unhealthy but returns OK
|
||||
}
|
||||
}
|
||||
|
||||
// Health check that blocks on slow dependencies
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
async check(): Promise<string> {
|
||||
// If database is slow, health check times out
|
||||
await this.userRepo.findOne({ where: { id: '1' } });
|
||||
await this.redis.ping();
|
||||
await this.externalApi.healthCheck();
|
||||
return 'OK';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use @nestjs/terminus for comprehensive health checks):**
|
||||
|
||||
```typescript
|
||||
// Use @nestjs/terminus for comprehensive health checks
|
||||
import {
|
||||
HealthCheckService,
|
||||
HttpHealthIndicator,
|
||||
TypeOrmHealthIndicator,
|
||||
HealthCheck,
|
||||
DiskHealthIndicator,
|
||||
MemoryHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private http: HttpHealthIndicator,
|
||||
private db: TypeOrmHealthIndicator,
|
||||
private disk: DiskHealthIndicator,
|
||||
private memory: MemoryHealthIndicator,
|
||||
) {}
|
||||
|
||||
// Liveness probe - is the service alive?
|
||||
@Get('live')
|
||||
@HealthCheck()
|
||||
liveness() {
|
||||
return this.health.check([
|
||||
// Basic checks only
|
||||
() => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB
|
||||
]);
|
||||
}
|
||||
|
||||
// Readiness probe - can the service handle traffic?
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
readiness() {
|
||||
return this.health.check([
|
||||
() => this.db.pingCheck('database'),
|
||||
() =>
|
||||
this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }),
|
||||
() =>
|
||||
this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
|
||||
]);
|
||||
}
|
||||
|
||||
// Deep health check for debugging
|
||||
@Get('deep')
|
||||
@HealthCheck()
|
||||
deepCheck() {
|
||||
return this.health.check([
|
||||
() => this.db.pingCheck('database'),
|
||||
() => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
|
||||
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
|
||||
() =>
|
||||
this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
|
||||
() =>
|
||||
this.http.pingCheck('external-api', 'https://api.example.com/health'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom indicator for business-specific health
|
||||
@Injectable()
|
||||
export class QueueHealthIndicator extends HealthIndicator {
|
||||
constructor(private queueService: QueueService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
||||
const queueStats = await this.queueService.getStats();
|
||||
|
||||
const isHealthy = queueStats.failedCount < 100;
|
||||
const result = this.getStatus(key, isHealthy, {
|
||||
waiting: queueStats.waitingCount,
|
||||
active: queueStats.activeCount,
|
||||
failed: queueStats.failedCount,
|
||||
});
|
||||
|
||||
if (!isHealthy) {
|
||||
throw new HealthCheckError('Queue unhealthy', result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Redis health indicator
|
||||
@Injectable()
|
||||
export class RedisHealthIndicator extends HealthIndicator {
|
||||
constructor(@InjectRedis() private redis: Redis) {
|
||||
super();
|
||||
}
|
||||
|
||||
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
||||
try {
|
||||
const pong = await this.redis.ping();
|
||||
return this.getStatus(key, pong === 'PONG');
|
||||
} catch (error) {
|
||||
throw new HealthCheckError('Redis check failed', this.getStatus(key, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom indicators
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
readiness() {
|
||||
return this.health.check([
|
||||
() => this.db.pingCheck('database'),
|
||||
() => this.redis.isHealthy('redis'),
|
||||
() => this.queue.isHealthy('job-queue'),
|
||||
]);
|
||||
}
|
||||
|
||||
// Graceful shutdown handling
|
||||
@Injectable()
|
||||
export class GracefulShutdownService implements OnApplicationShutdown {
|
||||
private isShuttingDown = false;
|
||||
|
||||
isShutdown(): boolean {
|
||||
return this.isShuttingDown;
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
console.log(`Shutting down on ${signal}`);
|
||||
|
||||
// Wait for in-flight requests
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
// Health check respects shutdown state
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
readiness() {
|
||||
if (this.shutdownService.isShutdown()) {
|
||||
throw new ServiceUnavailableException('Shutting down');
|
||||
}
|
||||
|
||||
return this.health.check([
|
||||
() => this.db.pingCheck('database'),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
### Kubernetes Configuration
|
||||
|
||||
```yaml
|
||||
# Kubernetes deployment with probes
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: api-service
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: api-service:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 3000
|
||||
initialDelaySeconds: 0
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30
|
||||
```
|
||||
|
||||
Reference: [NestJS Terminus](https://docs.nestjs.com/recipes/terminus)
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: Use Message and Event Patterns Correctly
|
||||
impact: MEDIUM
|
||||
impactDescription: Proper patterns ensure reliable microservice communication
|
||||
tags: microservices, message-pattern, event-pattern, communication
|
||||
---
|
||||
|
||||
## Use Message and Event Patterns Correctly
|
||||
|
||||
NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs.
|
||||
|
||||
**Incorrect (using wrong pattern for use case):**
|
||||
|
||||
```typescript
|
||||
// Use @MessagePattern for fire-and-forget
|
||||
@Controller()
|
||||
export class NotificationsController {
|
||||
@MessagePattern('user.created')
|
||||
async handleUserCreated(data: UserCreatedEvent) {
|
||||
// This WAITS for response, blocking the sender
|
||||
await this.emailService.sendWelcome(data.email);
|
||||
// If email fails, sender gets an error (coupling!)
|
||||
}
|
||||
}
|
||||
|
||||
// Use @EventPattern expecting a response
|
||||
@Controller()
|
||||
export class OrdersController {
|
||||
@EventPattern('inventory.check')
|
||||
async checkInventory(data: CheckInventoryDto) {
|
||||
const available = await this.inventory.check(data);
|
||||
return available; // This return value is IGNORED with @EventPattern!
|
||||
}
|
||||
}
|
||||
|
||||
// Tight coupling in client
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async createUser(dto: CreateUserDto): Promise<User> {
|
||||
const user = await this.repo.save(dto);
|
||||
|
||||
// Blocks until notification service responds
|
||||
await this.client.send('user.created', user).toPromise();
|
||||
// If notification service is down, user creation fails!
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):**
|
||||
|
||||
```typescript
|
||||
// MessagePattern: Request-Response (when you NEED a response)
|
||||
@Controller()
|
||||
export class InventoryController {
|
||||
@MessagePattern({ cmd: 'check_inventory' })
|
||||
async checkInventory(data: CheckInventoryDto): Promise<InventoryResult> {
|
||||
const result = await this.inventoryService.check(data.productId, data.quantity);
|
||||
return result; // Response sent back to caller
|
||||
}
|
||||
}
|
||||
|
||||
// Client expects response
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
||||
// Check inventory - we NEED this response to proceed
|
||||
const inventory = await firstValueFrom(
|
||||
this.inventoryClient.send<InventoryResult>(
|
||||
{ cmd: 'check_inventory' },
|
||||
{ productId: dto.productId, quantity: dto.quantity },
|
||||
),
|
||||
);
|
||||
|
||||
if (!inventory.available) {
|
||||
throw new BadRequestException('Insufficient inventory');
|
||||
}
|
||||
|
||||
return this.repo.save(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// EventPattern: Fire-and-Forget (for notifications, side effects)
|
||||
@Controller()
|
||||
export class NotificationsController {
|
||||
@EventPattern('user.created')
|
||||
async handleUserCreated(data: UserCreatedEvent): Promise<void> {
|
||||
// No return value needed - just process the event
|
||||
await this.emailService.sendWelcome(data.email);
|
||||
await this.analyticsService.track('user_signup', data);
|
||||
// If this fails, it doesn't affect the sender
|
||||
}
|
||||
}
|
||||
|
||||
// Client emits event without waiting
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async createUser(dto: CreateUserDto): Promise<User> {
|
||||
const user = await this.repo.save(dto);
|
||||
|
||||
// Fire and forget - doesn't block, doesn't wait
|
||||
this.eventClient.emit('user.created', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return user; // User creation succeeds regardless of event handling
|
||||
}
|
||||
}
|
||||
|
||||
// Hybrid pattern for critical events
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
async createOrder(dto: CreateOrderDto): Promise<Order> {
|
||||
const order = await this.repo.save(dto);
|
||||
|
||||
// Critical: inventory reservation (use MessagePattern)
|
||||
const reserved = await firstValueFrom(
|
||||
this.inventoryClient.send({ cmd: 'reserve_inventory' }, {
|
||||
orderId: order.id,
|
||||
items: dto.items,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!reserved.success) {
|
||||
await this.repo.delete(order.id);
|
||||
throw new BadRequestException('Could not reserve inventory');
|
||||
}
|
||||
|
||||
// Non-critical: notifications (use EventPattern)
|
||||
this.eventClient.emit('order.created', {
|
||||
orderId: order.id,
|
||||
userId: dto.userId,
|
||||
total: dto.total,
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling patterns
|
||||
// MessagePattern errors propagate to caller
|
||||
@MessagePattern({ cmd: 'get_user' })
|
||||
async getUser(userId: string): Promise<User> {
|
||||
const user = await this.repo.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new RpcException('User not found'); // Received by caller
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// EventPattern errors should be handled locally
|
||||
@EventPattern('order.created')
|
||||
async handleOrderCreated(data: OrderCreatedEvent): Promise<void> {
|
||||
try {
|
||||
await this.processOrder(data);
|
||||
} catch (error) {
|
||||
// Log and potentially retry - don't throw
|
||||
this.logger.error('Failed to process order event', error);
|
||||
await this.deadLetterQueue.add(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Microservices](https://docs.nestjs.com/microservices/basics)
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
title: Use Message Queues for Background Jobs
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Queues enable reliable background processing
|
||||
tags: microservices, queues, bullmq, background-jobs
|
||||
---
|
||||
|
||||
## Use Message Queues for Background Jobs
|
||||
|
||||
Use `@nestjs/bullmq` for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests.
|
||||
|
||||
**Incorrect (long-running tasks in HTTP handlers):**
|
||||
|
||||
```typescript
|
||||
// Long-running tasks in HTTP handlers
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
@Post()
|
||||
async generate(@Body() dto: GenerateReportDto): Promise<Report> {
|
||||
// This blocks the request for potentially minutes
|
||||
const data = await this.fetchLargeDataset(dto);
|
||||
const report = await this.processData(data); // Slow!
|
||||
await this.sendEmail(dto.email, report); // Can fail!
|
||||
return report; // Client times out
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget without retry
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
async sendWelcome(email: string): Promise<void> {
|
||||
// If this fails, email is never sent
|
||||
await this.mailer.send({ to: email, template: 'welcome' });
|
||||
// No retry, no tracking, no visibility
|
||||
}
|
||||
}
|
||||
|
||||
// Use setInterval for scheduled tasks
|
||||
setInterval(async () => {
|
||||
await cleanupOldRecords();
|
||||
}, 60000); // No error handling, memory leaks
|
||||
```
|
||||
|
||||
**Correct (use BullMQ for background processing):**
|
||||
|
||||
```typescript
|
||||
// Configure BullMQ
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.forRoot({
|
||||
connection: {
|
||||
host: 'localhost',
|
||||
port: 6379,
|
||||
},
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 5000,
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
BullModule.registerQueue({ name: 'email' }, { name: 'reports' }, { name: 'notifications' }),
|
||||
],
|
||||
})
|
||||
export class QueueModule {}
|
||||
|
||||
// Producer: Add jobs to queue
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(@InjectQueue('reports') private reportsQueue: Queue) {}
|
||||
|
||||
async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> {
|
||||
// Return immediately, process in background
|
||||
const job = await this.reportsQueue.add('generate', dto, {
|
||||
priority: dto.urgent ? 1 : 10,
|
||||
delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0,
|
||||
});
|
||||
|
||||
return { jobId: job.id };
|
||||
}
|
||||
|
||||
async getJobStatus(jobId: string): Promise<JobStatus> {
|
||||
const job = await this.reportsQueue.getJob(jobId);
|
||||
return {
|
||||
status: await job.getState(),
|
||||
progress: job.progress,
|
||||
result: job.returnvalue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer: Process jobs
|
||||
@Processor('reports')
|
||||
export class ReportsProcessor {
|
||||
private readonly logger = new Logger(ReportsProcessor.name);
|
||||
|
||||
@Process('generate')
|
||||
async generateReport(job: Job<GenerateReportDto>): Promise<Report> {
|
||||
this.logger.log(`Processing report job ${job.id}`);
|
||||
|
||||
// Update progress
|
||||
await job.updateProgress(10);
|
||||
|
||||
const data = await this.fetchData(job.data);
|
||||
await job.updateProgress(50);
|
||||
|
||||
const report = await this.processData(data);
|
||||
await job.updateProgress(90);
|
||||
|
||||
await this.saveReport(report);
|
||||
await job.updateProgress(100);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
@OnQueueActive()
|
||||
onActive(job: Job) {
|
||||
this.logger.log(`Processing job ${job.id}`);
|
||||
}
|
||||
|
||||
@OnQueueCompleted()
|
||||
onCompleted(job: Job, result: any) {
|
||||
this.logger.log(`Job ${job.id} completed`);
|
||||
}
|
||||
|
||||
@OnQueueFailed()
|
||||
onFailed(job: Job, error: Error) {
|
||||
this.logger.error(`Job ${job.id} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Email queue with retry
|
||||
@Processor('email')
|
||||
export class EmailProcessor {
|
||||
@Process('send')
|
||||
async sendEmail(job: Job<SendEmailDto>): Promise<void> {
|
||||
const { to, template, data } = job.data;
|
||||
|
||||
try {
|
||||
await this.mailer.send({
|
||||
to,
|
||||
template,
|
||||
context: data,
|
||||
});
|
||||
} catch (error) {
|
||||
// BullMQ will retry based on job options
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(@InjectQueue('email') private emailQueue: Queue) {}
|
||||
|
||||
async sendWelcome(user: User): Promise<void> {
|
||||
await this.emailQueue.add(
|
||||
'send',
|
||||
{
|
||||
to: user.email,
|
||||
template: 'welcome',
|
||||
data: { name: user.name },
|
||||
},
|
||||
{
|
||||
attempts: 5,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scheduled jobs
|
||||
@Injectable()
|
||||
export class ScheduledJobsService implements OnModuleInit {
|
||||
constructor(@InjectQueue('maintenance') private queue: Queue) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Clean up old reports daily at midnight
|
||||
await this.queue.add(
|
||||
'cleanup',
|
||||
{},
|
||||
{
|
||||
repeat: { cron: '0 0 * * *' },
|
||||
jobId: 'daily-cleanup', // Prevent duplicates
|
||||
}
|
||||
);
|
||||
|
||||
// Send digest every hour
|
||||
await this.queue.add(
|
||||
'digest',
|
||||
{},
|
||||
{
|
||||
repeat: { every: 60 * 60 * 1000 },
|
||||
jobId: 'hourly-digest',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Processor('maintenance')
|
||||
export class MaintenanceProcessor {
|
||||
@Process('cleanup')
|
||||
async cleanup(): Promise<void> {
|
||||
await this.cleanupOldReports();
|
||||
await this.cleanupExpiredSessions();
|
||||
}
|
||||
|
||||
@Process('digest')
|
||||
async sendDigest(): Promise<void> {
|
||||
const users = await this.getUsersForDigest();
|
||||
for (const user of users) {
|
||||
await this.emailQueue.add('send', { to: user.email, template: 'digest' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue monitoring with Bull Board
|
||||
import { BullBoardModule } from '@bull-board/nestjs';
|
||||
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullBoardModule.forRoot({
|
||||
route: '/admin/queues',
|
||||
adapter: ExpressAdapter,
|
||||
}),
|
||||
BullBoardModule.forFeature({
|
||||
name: 'email',
|
||||
adapter: BullMQAdapter,
|
||||
}),
|
||||
BullBoardModule.forFeature({
|
||||
name: 'reports',
|
||||
adapter: BullMQAdapter,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
```
|
||||
|
||||
Reference: [NestJS Queues](https://docs.nestjs.com/techniques/queues)
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: Use Async Lifecycle Hooks Correctly
|
||||
impact: HIGH
|
||||
impactDescription: Improper async handling blocks application startup
|
||||
tags: performance, lifecycle, async, hooks
|
||||
---
|
||||
|
||||
## Use Async Lifecycle Hooks Correctly
|
||||
|
||||
NestJS lifecycle hooks (`onModuleInit`, `onApplicationBootstrap`, etc.) support async operations. However, misusing them can block application startup or cause race conditions. Understand the lifecycle order and use hooks appropriately.
|
||||
|
||||
**Incorrect (fire-and-forget async without await):**
|
||||
|
||||
```typescript
|
||||
// Fire-and-forget async without await
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleInit {
|
||||
onModuleInit() {
|
||||
// This runs but doesn't block - app starts before DB is ready!
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
await this.pool.connect();
|
||||
console.log('Database connected');
|
||||
}
|
||||
}
|
||||
|
||||
// Heavy blocking operations in constructor
|
||||
@Injectable()
|
||||
export class ConfigService {
|
||||
private config: Config;
|
||||
|
||||
constructor() {
|
||||
// BLOCKS entire module instantiation synchronously
|
||||
this.config = fs.readFileSync('config.json');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (return promises from async hooks):**
|
||||
|
||||
```typescript
|
||||
// Return promise from async hooks
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleInit {
|
||||
private pool: Pool;
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// NestJS waits for this to complete before continuing
|
||||
await this.pool.connect();
|
||||
console.log('Database connected');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// Clean up resources on shutdown
|
||||
await this.pool.end();
|
||||
console.log('Database disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
// Use onApplicationBootstrap for cross-module dependencies
|
||||
@Injectable()
|
||||
export class CacheWarmerService implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private cache: CacheService,
|
||||
private products: ProductsService
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
// All modules are initialized, safe to warm cache
|
||||
const products = await this.products.findPopular();
|
||||
await this.cache.warmup(products);
|
||||
}
|
||||
}
|
||||
|
||||
// Heavy init in async hooks, not constructor
|
||||
@Injectable()
|
||||
export class ConfigService implements OnModuleInit {
|
||||
private config: Config;
|
||||
|
||||
constructor() {
|
||||
// Keep constructor synchronous and fast
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Async loading in lifecycle hook
|
||||
this.config = await this.loadConfig();
|
||||
}
|
||||
|
||||
private async loadConfig(): Promise<Config> {
|
||||
const file = await fs.promises.readFile('config.json');
|
||||
return JSON.parse(file.toString());
|
||||
}
|
||||
|
||||
get<T>(key: string): T {
|
||||
return this.config[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Enable shutdown hooks in main.ts
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.enableShutdownHooks(); // Enable SIGTERM/SIGINT handling
|
||||
await app.listen(3000);
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Lifecycle Events](https://docs.nestjs.com/fundamentals/lifecycle-events)
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Use Lazy Loading for Large Modules
|
||||
impact: MEDIUM
|
||||
impactDescription: Improves startup time for large applications
|
||||
tags: performance, lazy-loading, modules, optimization
|
||||
---
|
||||
|
||||
## Use Lazy Loading for Large Modules
|
||||
|
||||
NestJS supports lazy-loading modules, which defers initialization until first use. This is valuable for large applications where some features are rarely used, serverless deployments where cold start time matters, or when certain modules have heavy initialization costs.
|
||||
|
||||
**Incorrect (loading everything eagerly):**
|
||||
|
||||
```typescript
|
||||
// Load everything eagerly in a large app
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
OrdersModule,
|
||||
PaymentsModule,
|
||||
ReportsModule, // Heavy, rarely used
|
||||
AnalyticsModule, // Heavy, rarely used
|
||||
AdminModule, // Only admins use this
|
||||
LegacyModule, // Migration module, rarely used
|
||||
BulkImportModule, // Used once a month
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// All modules initialize at startup, even if never used
|
||||
// Slow cold starts in serverless
|
||||
// Memory wasted on unused modules
|
||||
```
|
||||
|
||||
**Correct (lazy load rarely-used modules):**
|
||||
|
||||
```typescript
|
||||
// Use LazyModuleLoader for optional modules
|
||||
import { LazyModuleLoader } from '@nestjs/core';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
constructor(private lazyModuleLoader: LazyModuleLoader) {}
|
||||
|
||||
async generateReport(type: string): Promise<Report> {
|
||||
// Load module only when needed
|
||||
const { ReportsModule } = await import('./reports/reports.module');
|
||||
const moduleRef = await this.lazyModuleLoader.load(() => ReportsModule);
|
||||
|
||||
const reportsService = moduleRef.get(ReportsGeneratorService);
|
||||
return reportsService.generate(type);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load admin features with caching
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private adminModule: ModuleRef | null = null;
|
||||
|
||||
constructor(private lazyModuleLoader: LazyModuleLoader) {}
|
||||
|
||||
private async getAdminModule(): Promise<ModuleRef> {
|
||||
if (!this.adminModule) {
|
||||
const { AdminModule } = await import('./admin/admin.module');
|
||||
this.adminModule = await this.lazyModuleLoader.load(() => AdminModule);
|
||||
}
|
||||
return this.adminModule;
|
||||
}
|
||||
|
||||
async runAdminTask(task: string): Promise<void> {
|
||||
const moduleRef = await this.getAdminModule();
|
||||
const taskRunner = moduleRef.get(AdminTaskRunner);
|
||||
await taskRunner.run(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Reusable lazy loader service
|
||||
@Injectable()
|
||||
export class ModuleLoaderService {
|
||||
private loadedModules = new Map<string, ModuleRef>();
|
||||
|
||||
constructor(private lazyModuleLoader: LazyModuleLoader) {}
|
||||
|
||||
async load<T>(key: string, importFn: () => Promise<{ default: Type<T> } | Type<T>>): Promise<ModuleRef> {
|
||||
if (!this.loadedModules.has(key)) {
|
||||
const module = await importFn();
|
||||
const moduleType = 'default' in module ? module.default : module;
|
||||
const moduleRef = await this.lazyModuleLoader.load(() => moduleType);
|
||||
this.loadedModules.set(key, moduleRef);
|
||||
}
|
||||
return this.loadedModules.get(key)!;
|
||||
}
|
||||
}
|
||||
|
||||
// Preload modules in background after startup
|
||||
@Injectable()
|
||||
export class ModulePreloader implements OnApplicationBootstrap {
|
||||
constructor(private lazyModuleLoader: LazyModuleLoader) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
setTimeout(async () => {
|
||||
await this.preloadModule(() => import('./reports/reports.module'));
|
||||
}, 5000); // 5 seconds after startup
|
||||
}
|
||||
|
||||
private async preloadModule(importFn: () => Promise<any>): Promise<void> {
|
||||
try {
|
||||
const module = await importFn();
|
||||
const moduleType = module.default || Object.values(module)[0];
|
||||
await this.lazyModuleLoader.load(() => moduleType);
|
||||
} catch (error) {
|
||||
console.warn('Failed to preload module', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Lazy Loading Modules](https://docs.nestjs.com/fundamentals/lazy-loading-modules)
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: Optimize Database Queries
|
||||
impact: HIGH
|
||||
impactDescription: Database queries are typically the largest source of latency
|
||||
tags: performance, database, queries, optimization
|
||||
---
|
||||
|
||||
## Optimize Database Queries
|
||||
|
||||
Select only needed columns, use proper indexes, avoid over-fetching relations, and consider query performance when designing your data access. Most API slowness traces back to inefficient database queries.
|
||||
|
||||
**Incorrect (over-fetching data and missing indexes):**
|
||||
|
||||
```typescript
|
||||
// Select everything when you need few fields
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async findAllEmails(): Promise<string[]> {
|
||||
const users = await this.repo.find();
|
||||
// Fetches ALL columns for ALL users
|
||||
return users.map((u) => u.email);
|
||||
}
|
||||
|
||||
async getUserSummary(id: string): Promise<UserSummary> {
|
||||
const user = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: ['posts', 'posts.comments', 'posts.comments.author', 'followers'],
|
||||
});
|
||||
// Over-fetches massive relation tree
|
||||
return { name: user.name, postCount: user.posts.length };
|
||||
}
|
||||
}
|
||||
|
||||
// No indexes on frequently queried columns
|
||||
@Entity()
|
||||
export class Order {
|
||||
@Column()
|
||||
userId: string; // No index - full table scan on every lookup
|
||||
|
||||
@Column()
|
||||
status: string; // No index - slow status filtering
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (select only needed data with proper indexes):**
|
||||
|
||||
```typescript
|
||||
// Select only needed columns
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
async findAllEmails(): Promise<string[]> {
|
||||
const users = await this.repo.find({
|
||||
select: ['email'], // Only fetch email column
|
||||
});
|
||||
return users.map((u) => u.email);
|
||||
}
|
||||
|
||||
// Use QueryBuilder for complex selections
|
||||
async getUserSummary(id: string): Promise<UserSummary> {
|
||||
return this.repo
|
||||
.createQueryBuilder('user')
|
||||
.select('user.name', 'name')
|
||||
.addSelect('COUNT(post.id)', 'postCount')
|
||||
.leftJoin('user.posts', 'post')
|
||||
.where('user.id = :id', { id })
|
||||
.groupBy('user.id')
|
||||
.getRawOne();
|
||||
}
|
||||
|
||||
// Fetch relations only when needed
|
||||
async getFullProfile(id: string): Promise<User> {
|
||||
return this.repo.findOne({
|
||||
where: { id },
|
||||
relations: ['posts'], // Only immediate relation
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
posts: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add indexes on frequently queried columns
|
||||
@Entity()
|
||||
@Index(['userId'])
|
||||
@Index(['status'])
|
||||
@Index(['createdAt'])
|
||||
@Index(['userId', 'status']) // Composite index for common query pattern
|
||||
export class Order {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
userId: string;
|
||||
|
||||
@Column()
|
||||
status: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// Always paginate large datasets
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
async findAll(page = 1, limit = 20): Promise<PaginatedResult<Order>> {
|
||||
const [items, total] = await this.repo.findAndCount({
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [TypeORM Query Builder](https://typeorm.io/select-query-builder)
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Use Caching Strategically
|
||||
impact: HIGH
|
||||
impactDescription: Dramatically reduces database load and response times
|
||||
tags: performance, caching, redis, optimization
|
||||
---
|
||||
|
||||
## Use Caching Strategically
|
||||
|
||||
Implement caching for expensive operations, frequently accessed data, and external API calls. Use NestJS CacheModule with appropriate TTLs and cache invalidation strategies. Don't cache everything - focus on high-impact areas.
|
||||
|
||||
**Incorrect (no caching or caching everything):**
|
||||
|
||||
```typescript
|
||||
// No caching for expensive, repeated queries
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
async getPopular(): Promise<Product[]> {
|
||||
// Runs complex aggregation query EVERY request
|
||||
return this.productsRepo
|
||||
.createQueryBuilder('p')
|
||||
.leftJoin('p.orders', 'o')
|
||||
.select('p.*, COUNT(o.id) as orderCount')
|
||||
.groupBy('p.id')
|
||||
.orderBy('orderCount', 'DESC')
|
||||
.limit(20)
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
// Cache everything without thought
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@CacheKey('users')
|
||||
@CacheTTL(3600)
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
async findAll(): Promise<User[]> {
|
||||
// Caching user list for 1 hour is wrong if data changes frequently
|
||||
return this.usersRepo.find();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (strategic caching with proper invalidation):**
|
||||
|
||||
```typescript
|
||||
// Setup caching module
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
stores: [new KeyvRedis(config.get('REDIS_URL'))],
|
||||
ttl: 60 * 1000, // Default 60s
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Manual caching for granular control
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER) private cache: Cache,
|
||||
private productsRepo: ProductRepository
|
||||
) {}
|
||||
|
||||
async getPopular(): Promise<Product[]> {
|
||||
const cacheKey = 'products:popular';
|
||||
|
||||
// Try cache first
|
||||
const cached = await this.cache.get<Product[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Cache miss - fetch and cache
|
||||
const products = await this.fetchPopularProducts();
|
||||
await this.cache.set(cacheKey, products, 5 * 60 * 1000); // 5 min TTL
|
||||
return products;
|
||||
}
|
||||
|
||||
// Invalidate cache on changes
|
||||
async updateProduct(id: string, dto: UpdateProductDto): Promise<Product> {
|
||||
const product = await this.productsRepo.save({ id, ...dto });
|
||||
await this.cache.del('products:popular'); // Invalidate
|
||||
return product;
|
||||
}
|
||||
}
|
||||
|
||||
// Decorator-based caching with auto-interceptor
|
||||
@Controller('categories')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
export class CategoriesController {
|
||||
@Get()
|
||||
@CacheTTL(30 * 60 * 1000) // 30 minutes - categories rarely change
|
||||
findAll(): Promise<Category[]> {
|
||||
return this.categoriesService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@CacheTTL(60 * 1000) // 1 minute
|
||||
@CacheKey('category')
|
||||
findOne(@Param('id') id: string): Promise<Category> {
|
||||
return this.categoriesService.findOne(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Event-based cache invalidation
|
||||
@Injectable()
|
||||
export class CacheInvalidationService {
|
||||
constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
|
||||
|
||||
@OnEvent('product.created')
|
||||
@OnEvent('product.updated')
|
||||
@OnEvent('product.deleted')
|
||||
async invalidateProductCaches(event: ProductEvent) {
|
||||
await Promise.all([this.cache.del('products:popular'), this.cache.del(`product:${event.productId}`)]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Caching](https://docs.nestjs.com/techniques/caching)
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: Implement Secure JWT Authentication
|
||||
impact: CRITICAL
|
||||
impactDescription: Essential for secure APIs
|
||||
tags: security, jwt, authentication, tokens
|
||||
---
|
||||
|
||||
## Implement Secure JWT Authentication
|
||||
|
||||
Use `@nestjs/jwt` with `@nestjs/passport` for authentication. Store secrets securely, use appropriate token lifetimes, implement refresh tokens, and validate tokens properly. Never expose sensitive data in JWT payloads.
|
||||
|
||||
**Incorrect (insecure JWT implementation):**
|
||||
|
||||
```typescript
|
||||
// Hardcode secrets
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: 'my-secret-key', // Exposed in code
|
||||
signOptions: { expiresIn: '7d' }, // Too long
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
// Store sensitive data in JWT
|
||||
async login(user: User): Promise<{ accessToken: string }> {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
password: user.password, // NEVER include password!
|
||||
ssn: user.ssn, // NEVER include sensitive data!
|
||||
isAdmin: user.isAdmin, // Can be tampered if not verified
|
||||
};
|
||||
return { accessToken: this.jwtService.sign(payload) };
|
||||
}
|
||||
|
||||
// Skip token validation
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: 'my-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any): Promise<any> {
|
||||
return payload; // No validation of user existence
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (secure JWT with refresh tokens):**
|
||||
|
||||
```typescript
|
||||
// Secure JWT configuration
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: '15m', // Short-lived access tokens
|
||||
issuer: config.get<string>('JWT_ISSUER'),
|
||||
audience: config.get<string>('JWT_AUDIENCE'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
// Minimal JWT payload
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
async login(user: User): Promise<TokenResponse> {
|
||||
// Only include necessary, non-sensitive data
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles: user.roles,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshToken = await this.createRefreshToken(user.id);
|
||||
|
||||
return { accessToken, refreshToken, expiresIn: 900 };
|
||||
}
|
||||
|
||||
private async createRefreshToken(userId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const hashedToken = await bcrypt.hash(token, 10);
|
||||
|
||||
await this.refreshTokenRepo.save({
|
||||
userId,
|
||||
token: hashedToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
// Proper JWT strategy with validation
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private usersService: UsersService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.get<string>('JWT_SECRET'),
|
||||
ignoreExpiration: false,
|
||||
issuer: config.get<string>('JWT_ISSUER'),
|
||||
audience: config.get<string>('JWT_AUDIENCE'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<User> {
|
||||
// Verify user still exists and is active
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found or inactive');
|
||||
}
|
||||
|
||||
// Verify token wasn't issued before password change
|
||||
if (user.passwordChangedAt) {
|
||||
const tokenIssuedAt = new Date(payload.iat * 1000);
|
||||
if (tokenIssuedAt < user.passwordChangedAt) {
|
||||
throw new UnauthorizedException('Token invalidated by password change');
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Authentication](https://docs.nestjs.com/security/authentication)
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Two-Phase File Upload + ClamAV (ADR-016)
|
||||
impact: CRITICAL
|
||||
impactDescription: Upload → Temp → ClamAV scan → Commit → Permanent. Whitelist + 50MB cap. StorageService only.
|
||||
tags: file-upload, clamav, security, adr-016, storage
|
||||
---
|
||||
|
||||
## Two-Phase File Upload (ADR-016)
|
||||
|
||||
**Never write uploaded files directly to permanent storage.** All uploads must go through:
|
||||
|
||||
```
|
||||
Client → Upload endpoint → Temp storage → ClamAV scan → Commit endpoint → Permanent storage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Constraints (non-negotiable)
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Allowed MIME types | `application/pdf`, `image/vnd.dwg`, `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`, `application/zip` |
|
||||
| Allowed extensions | `.pdf`, `.dwg`, `.docx`, `.xlsx`, `.zip` |
|
||||
| Max size | 50 MB |
|
||||
| Temp TTL | 24 h (purged by cron) |
|
||||
| Virus scan | ClamAV (blocking) |
|
||||
| Mover | `StorageService` only — never `fs.rename` directly from controller |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Upload to Temp
|
||||
|
||||
```typescript
|
||||
@Post('upload')
|
||||
@UseGuards(JwtAuthGuard, ThrottlerGuard)
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
|
||||
}))
|
||||
async uploadTemp(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: User,
|
||||
): Promise<{ tempId: string; expiresAt: string }> {
|
||||
// 1. Validate MIME + extension (defense in depth)
|
||||
this.fileValidator.assertAllowed(file);
|
||||
|
||||
// 2. Scan with ClamAV
|
||||
const scanResult = await this.clamavService.scan(file.buffer);
|
||||
if (!scanResult.clean) {
|
||||
throw new BusinessException(
|
||||
`ClamAV rejected: ${scanResult.signature}`,
|
||||
'ไฟล์ไม่ปลอดภัย ระบบตรวจพบความเสี่ยง',
|
||||
'กรุณาตรวจสอบไฟล์และลองใหม่อีกครั้ง',
|
||||
'FILE_INFECTED',
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Save to temp (encrypted at rest)
|
||||
const tempId = await this.storageService.saveToTemp(file, user.id);
|
||||
|
||||
return {
|
||||
tempId,
|
||||
expiresAt: addHours(new Date(), 24).toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Commit in Transaction
|
||||
|
||||
The business operation (e.g., creating a Correspondence) promotes temp files to permanent **in the same DB transaction**.
|
||||
|
||||
```typescript
|
||||
async createCorrespondence(dto: CreateCorrespondenceDto, user: User) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// 1. Create domain entity
|
||||
const entity = await manager.save(Correspondence, {
|
||||
...dto,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
// 2. Commit temp files → permanent (ACID together with entity)
|
||||
await this.storageService.commitFiles(
|
||||
dto.tempFileIds,
|
||||
{ entityId: entity.id, entityType: 'correspondence' },
|
||||
manager,
|
||||
);
|
||||
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
If the transaction rolls back, temp files remain and expire in 24h — no orphaned permanent files.
|
||||
|
||||
---
|
||||
|
||||
## StorageService Contract
|
||||
|
||||
```typescript
|
||||
export interface StorageService {
|
||||
saveToTemp(file: Express.Multer.File, ownerId: number): Promise<string>;
|
||||
commitFiles(
|
||||
tempIds: string[],
|
||||
target: { entityId: number; entityType: string },
|
||||
manager: EntityManager,
|
||||
): Promise<FileRecord[]>;
|
||||
purgeExpiredTemp(): Promise<number>; // called by cron
|
||||
getPermanentPath(fileId: number): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Forbidden
|
||||
|
||||
```typescript
|
||||
// ❌ Direct write to permanent
|
||||
fs.writeFileSync(`/var/storage/${file.originalname}`, file.buffer);
|
||||
|
||||
// ❌ Skip ClamAV
|
||||
await this.storageService.savePermanent(file);
|
||||
|
||||
// ❌ Non-whitelist MIME
|
||||
@UseInterceptors(FileInterceptor('file')) // no size or type limit
|
||||
|
||||
// ❌ Commit outside transaction
|
||||
const entity = await this.repo.save(...);
|
||||
await this.storageService.commitFiles(tempIds, ...); // race: entity exists, files may fail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-016 Security & Authentication](../../../../specs/06-Decision-Records/ADR-016-security-authentication.md)
|
||||
- [Edge Cases](../../../../specs/01-Requirements/01-06-edge-cases-and-rules.md) — file upload scenarios
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Implement Rate Limiting
|
||||
impact: HIGH
|
||||
impactDescription: Protects against abuse and ensures fair resource usage
|
||||
tags: security, rate-limiting, throttler, protection
|
||||
---
|
||||
|
||||
## Implement Rate Limiting
|
||||
|
||||
Use `@nestjs/throttler` to limit request rates per client. Apply different limits for different endpoints - stricter for auth endpoints, more relaxed for read operations. Consider using Redis for distributed rate limiting in clustered deployments.
|
||||
|
||||
**Incorrect (no rate limiting on sensitive endpoints):**
|
||||
|
||||
```typescript
|
||||
// No rate limiting on sensitive endpoints
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@Post('login')
|
||||
async login(@Body() dto: LoginDto): Promise<TokenResponse> {
|
||||
// Attackers can brute-force credentials
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password')
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> {
|
||||
// Can be abused to spam users with emails
|
||||
return this.authService.sendResetEmail(dto.email);
|
||||
}
|
||||
}
|
||||
|
||||
// Same limits for all endpoints
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Controller('api')
|
||||
export class ApiController {
|
||||
@Get('public-data')
|
||||
async getPublic() {} // Should allow more requests
|
||||
|
||||
@Post('process-payment')
|
||||
async payment() {} // Should be more restrictive
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (configured throttler with endpoint-specific limits):**
|
||||
|
||||
```typescript
|
||||
// Configure throttler globally with multiple limits
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
name: 'short',
|
||||
ttl: 1000, // 1 second
|
||||
limit: 3, // 3 requests per second
|
||||
},
|
||||
{
|
||||
name: 'medium',
|
||||
ttl: 10000, // 10 seconds
|
||||
limit: 20, // 20 requests per 10 seconds
|
||||
},
|
||||
{
|
||||
name: 'long',
|
||||
ttl: 60000, // 1 minute
|
||||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Override limits per endpoint
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@Post('login')
|
||||
@Throttle({ short: { limit: 5, ttl: 60000 } }) // 5 attempts per minute
|
||||
async login(@Body() dto: LoginDto): Promise<TokenResponse> {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
@Post('forgot-password')
|
||||
@Throttle({ short: { limit: 3, ttl: 3600000 } }) // 3 per hour
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<void> {
|
||||
return this.authService.sendResetEmail(dto.email);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip throttling for certain routes
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@SkipThrottle()
|
||||
check(): string {
|
||||
return 'OK';
|
||||
}
|
||||
}
|
||||
|
||||
// Custom throttle per user type
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
protected async getTracker(req: Request): Promise<string> {
|
||||
// Use user ID if authenticated, IP otherwise
|
||||
return req.user?.id || req.ip;
|
||||
}
|
||||
|
||||
protected async getLimit(context: ExecutionContext): Promise<number> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Higher limits for authenticated users
|
||||
if (request.user) {
|
||||
return request.user.isPremium ? 1000 : 200;
|
||||
}
|
||||
|
||||
return 50; // Anonymous users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Throttler](https://docs.nestjs.com/security/rate-limiting)
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
title: Sanitize Output to Prevent XSS
|
||||
impact: HIGH
|
||||
impactDescription: XSS vulnerabilities can compromise user sessions and data
|
||||
tags: security, xss, sanitization, html
|
||||
---
|
||||
|
||||
## Sanitize Output to Prevent XSS
|
||||
|
||||
While NestJS APIs typically return JSON (which browsers don't execute), XSS risks exist when rendering HTML, storing user content, or when frontend frameworks improperly handle API responses. Sanitize user-generated content before storage and use proper Content-Type headers.
|
||||
|
||||
**Incorrect (storing raw HTML without sanitization):**
|
||||
|
||||
```typescript
|
||||
// Store raw HTML from users
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
async create(dto: CreateCommentDto): Promise<Comment> {
|
||||
// User can inject: <script>steal(document.cookie)</script>
|
||||
return this.repo.save({
|
||||
content: dto.content, // Raw, unsanitized
|
||||
authorId: dto.authorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return HTML without sanitization
|
||||
@Controller('pages')
|
||||
export class PagesController {
|
||||
@Get(':slug')
|
||||
@Header('Content-Type', 'text/html')
|
||||
async getPage(@Param('slug') slug: string): Promise<string> {
|
||||
const page = await this.pagesService.findBySlug(slug);
|
||||
// If page.content contains user input, XSS is possible
|
||||
return `<html><body>${page.content}</body></html>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Reflect user input in errors
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<User> {
|
||||
const user = await this.repo.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
// XSS if id contains malicious content and error is rendered
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (sanitize content and use proper headers):**
|
||||
|
||||
```typescript
|
||||
// Sanitize HTML content before storage
|
||||
import * as sanitizeHtml from 'sanitize-html';
|
||||
|
||||
@Injectable()
|
||||
export class CommentsService {
|
||||
private readonly sanitizeOptions: sanitizeHtml.IOptions = {
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'title'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
};
|
||||
|
||||
async create(dto: CreateCommentDto): Promise<Comment> {
|
||||
return this.repo.save({
|
||||
content: sanitizeHtml(dto.content, this.sanitizeOptions),
|
||||
authorId: dto.authorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use validation pipe to strip HTML
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class CreatePostDto {
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@Transform(({ value }) => sanitizeHtml(value, { allowedTags: [] }))
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@Transform(({ value }) =>
|
||||
sanitizeHtml(value, {
|
||||
allowedTags: ['p', 'br', 'b', 'i', 'a'],
|
||||
allowedAttributes: { a: ['href'] },
|
||||
}),
|
||||
)
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Set proper Content-Type headers
|
||||
@Controller('api')
|
||||
export class ApiController {
|
||||
@Get('data')
|
||||
@Header('Content-Type', 'application/json')
|
||||
async getData(): Promise<DataResponse> {
|
||||
// JSON response - browser won't execute scripts
|
||||
return this.service.getData();
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize error messages
|
||||
@Get(':id')
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
|
||||
const user = await this.repo.findOne({ where: { id } });
|
||||
if (!user) {
|
||||
// UUID validation ensures safe format
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// Use Helmet for CSP headers
|
||||
import helmet from 'helmet';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [OWASP XSS Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html)
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: Use Guards for Authentication and Authorization
|
||||
impact: HIGH
|
||||
impactDescription: Enforces access control before handlers execute
|
||||
tags: security, guards, authentication, authorization
|
||||
---
|
||||
|
||||
## Use Guards for Authentication and Authorization
|
||||
|
||||
Guards determine whether a request should be handled based on authentication state, roles, permissions, or other conditions. They run after middleware but before pipes and interceptors, making them ideal for access control. Use guards instead of manual checks in controllers.
|
||||
|
||||
**Incorrect (manual auth checks in every handler):**
|
||||
|
||||
```typescript
|
||||
// Manual auth checks in every handler
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
@Get('users')
|
||||
async getUsers(@Request() req) {
|
||||
if (!req.user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!req.user.roles.includes('admin')) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return this.adminService.getUsers();
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@Request() req, @Param('id') id: string) {
|
||||
if (!req.user) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!req.user.roles.includes('admin')) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return this.adminService.deleteUser(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (guards with declarative decorators):**
|
||||
|
||||
```typescript
|
||||
// JWT Auth Guard
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private reflector: Reflector
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Check for @Public() decorator
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [context.getHandler(), context.getClass()]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
request.user = await this.jwtService.verifyAsync(token);
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Roles Guard
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [context.getHandler(), context.getClass()]);
|
||||
|
||||
if (!requiredRoles) return true;
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
return requiredRoles.some((role) => user.roles?.includes(role));
|
||||
}
|
||||
}
|
||||
|
||||
// Decorators
|
||||
export const Public = () => SetMetadata('isPublic', true);
|
||||
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
|
||||
|
||||
// Register guards globally
|
||||
@Module({
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: RolesGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
// Clean controller
|
||||
@Controller('admin')
|
||||
@Roles(Role.Admin) // Applied to all routes
|
||||
export class AdminController {
|
||||
@Get('users')
|
||||
getUsers(): Promise<User[]> {
|
||||
return this.adminService.getUsers();
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
deleteUser(@Param('id') id: string): Promise<void> {
|
||||
return this.adminService.deleteUser(id);
|
||||
}
|
||||
|
||||
@Public() // Override: no auth required
|
||||
@Get('health')
|
||||
health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Guards](https://docs.nestjs.com/guards)
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
title: Validate All Input with DTOs and Pipes
|
||||
impact: HIGH
|
||||
impactDescription: First line of defense against attacks
|
||||
tags: security, validation, dto, pipes
|
||||
---
|
||||
|
||||
## Validate All Input with DTOs and Pipes
|
||||
|
||||
Always validate incoming data using class-validator decorators on DTOs and the global ValidationPipe. Never trust user input. Validate all request bodies, query parameters, and route parameters before processing.
|
||||
|
||||
**Incorrect (trust raw input without validation):**
|
||||
|
||||
```typescript
|
||||
// Trust raw input without validation
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Post()
|
||||
create(@Body() body: any) {
|
||||
// body could contain anything - SQL injection, XSS, etc.
|
||||
return this.usersService.create(body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: any) {
|
||||
// query.limit could be "'; DROP TABLE users; --"
|
||||
return this.usersService.findAll(query.limit);
|
||||
}
|
||||
}
|
||||
|
||||
// DTOs without validation decorators
|
||||
export class CreateUserDto {
|
||||
name: string; // No validation
|
||||
email: string; // Could be "not-an-email"
|
||||
age: number; // Could be "abc" or -999
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (validated DTOs with global ValidationPipe):**
|
||||
|
||||
```typescript
|
||||
// Enable ValidationPipe globally in main.ts
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true, // Strip unknown properties
|
||||
forbidNonWhitelisted: true, // Throw on unknown properties
|
||||
transform: true, // Auto-transform to DTO types
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await app.listen(3000);
|
||||
}
|
||||
|
||||
// Create well-validated DTOs
|
||||
import {
|
||||
IsString,
|
||||
IsEmail,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsOptional,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Matches,
|
||||
IsNotEmpty,
|
||||
} from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
@Transform(({ value }) => value?.trim())
|
||||
name: string;
|
||||
|
||||
@IsEmail()
|
||||
@Transform(({ value }) => value?.toLowerCase().trim())
|
||||
email: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(150)
|
||||
age: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(100)
|
||||
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
|
||||
message: 'Password must contain uppercase, lowercase, and number',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Query DTO with defaults and transformation
|
||||
export class FindUsersQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
offset: number = 0;
|
||||
}
|
||||
|
||||
// Param validation
|
||||
export class UserIdParamDto {
|
||||
@IsUUID('4')
|
||||
id: string;
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
@Post()
|
||||
create(@Body() dto: CreateUserDto): Promise<User> {
|
||||
// dto is guaranteed to be valid
|
||||
return this.usersService.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Query() query: FindUsersQueryDto): Promise<User[]> {
|
||||
// query.limit is a number, query.search is sanitized
|
||||
return this.usersService.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param() params: UserIdParamDto): Promise<User> {
|
||||
// params.id is a valid UUID
|
||||
return this.usersService.findById(params.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Validation](https://docs.nestjs.com/techniques/validation)
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Use Supertest for E2E Testing
|
||||
impact: HIGH
|
||||
impactDescription: Validates the full request/response cycle
|
||||
tags: testing, e2e, supertest, integration
|
||||
---
|
||||
|
||||
## Use Supertest for E2E Testing
|
||||
|
||||
End-to-end tests use Supertest to make real HTTP requests against your NestJS application. They test the full stack including middleware, guards, pipes, and interceptors. E2E tests catch integration issues that unit tests miss.
|
||||
|
||||
**Incorrect (no proper E2E setup or teardown):**
|
||||
|
||||
```typescript
|
||||
// Only unit test controllers
|
||||
describe('UsersController', () => {
|
||||
it('should return users', async () => {
|
||||
const service = { findAll: jest.fn().mockResolvedValue([]) };
|
||||
const controller = new UsersController(service as any);
|
||||
|
||||
const result = await controller.findAll();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
// Doesn't test: routes, guards, pipes, serialization
|
||||
});
|
||||
});
|
||||
|
||||
// E2E tests without proper setup/teardown
|
||||
describe('Users API', () => {
|
||||
it('should create user', async () => {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
// No proper initialization
|
||||
// No cleanup after test
|
||||
// Hits real database
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (proper E2E setup with Supertest):**
|
||||
|
||||
```typescript
|
||||
// Proper E2E test setup
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import * as request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('UsersController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
|
||||
// Apply same config as production
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('/users (POST)', () => {
|
||||
it('should create a user', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ name: 'John', email: 'john@test.com' })
|
||||
.expect(201)
|
||||
.expect((res) => {
|
||||
expect(res.body).toHaveProperty('id');
|
||||
expect(res.body.name).toBe('John');
|
||||
expect(res.body.email).toBe('john@test.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid email', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post('/users')
|
||||
.send({ name: 'John', email: 'invalid-email' })
|
||||
.expect(400)
|
||||
.expect((res) => {
|
||||
expect(res.body.message).toContain('email');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/users/:id (GET)', () => {
|
||||
it('should return 404 for non-existent user', () => {
|
||||
return request(app.getHttpServer()).get('/users/non-existent-id').expect(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Testing with authentication
|
||||
describe('Protected Routes (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let authToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
|
||||
await app.init();
|
||||
|
||||
// Get auth token
|
||||
const loginResponse = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ email: 'test@test.com', password: 'password' });
|
||||
|
||||
authToken = loginResponse.body.accessToken;
|
||||
});
|
||||
|
||||
it('should return 401 without token', () => {
|
||||
return request(app.getHttpServer()).get('/users/me').expect(401);
|
||||
});
|
||||
|
||||
it('should return user profile with valid token', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/users/me')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.email).toBe('test@test.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Database isolation for E2E tests
|
||||
describe('Orders API (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
envFilePath: '.env.test', // Test database config
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
dataSource = moduleFixture.get(DataSource);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean database between tests
|
||||
await dataSource.synchronize(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dataSource.destroy();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [NestJS E2E Testing](https://docs.nestjs.com/fundamentals/testing#end-to-end-testing)
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Mock External Services in Tests
|
||||
impact: HIGH
|
||||
impactDescription: Ensures fast, reliable, deterministic tests
|
||||
tags: testing, mocking, external-services, jest
|
||||
---
|
||||
|
||||
## Mock External Services in Tests
|
||||
|
||||
Never call real external services (APIs, databases, message queues) in unit tests. Mock them to ensure tests are fast, deterministic, and don't incur costs. Use realistic mock data and test edge cases like timeouts and errors.
|
||||
|
||||
**Incorrect (calling real APIs and databases):**
|
||||
|
||||
```typescript
|
||||
// Call real APIs in tests
|
||||
describe('PaymentService', () => {
|
||||
it('should process payment', async () => {
|
||||
const service = new PaymentService(new StripeClient(realApiKey));
|
||||
// Hits real Stripe API!
|
||||
const result = await service.charge('tok_visa', 1000);
|
||||
// Slow, costs money, flaky
|
||||
});
|
||||
});
|
||||
|
||||
// Use real database
|
||||
describe('UsersService', () => {
|
||||
beforeEach(async () => {
|
||||
await connection.query('DELETE FROM users'); // Modifies real DB
|
||||
});
|
||||
|
||||
it('should create user', async () => {
|
||||
const user = await service.create({ email: 'test@test.com' });
|
||||
// Side effects on shared database
|
||||
});
|
||||
});
|
||||
|
||||
// Incomplete mocks
|
||||
const mockHttpService = {
|
||||
get: jest.fn().mockResolvedValue({ data: {} }),
|
||||
// Missing error scenarios, missing other methods
|
||||
};
|
||||
```
|
||||
|
||||
**Correct (mock all external dependencies):**
|
||||
|
||||
```typescript
|
||||
// Mock HTTP service properly
|
||||
describe('WeatherService', () => {
|
||||
let service: WeatherService;
|
||||
let httpService: jest.Mocked<HttpService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
WeatherService,
|
||||
{
|
||||
provide: HttpService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(WeatherService);
|
||||
httpService = module.get(HttpService);
|
||||
});
|
||||
|
||||
it('should return weather data', async () => {
|
||||
const mockResponse = {
|
||||
data: { temperature: 72, humidity: 45 },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: {},
|
||||
};
|
||||
|
||||
httpService.get.mockReturnValue(of(mockResponse));
|
||||
|
||||
const result = await service.getWeather('NYC');
|
||||
|
||||
expect(result).toEqual({ temperature: 72, humidity: 45 });
|
||||
});
|
||||
|
||||
it('should handle API timeout', async () => {
|
||||
httpService.get.mockReturnValue(throwError(() => new Error('ETIMEDOUT')));
|
||||
|
||||
await expect(service.getWeather('NYC')).rejects.toThrow('Weather service unavailable');
|
||||
});
|
||||
|
||||
it('should handle rate limiting', async () => {
|
||||
httpService.get.mockReturnValue(
|
||||
throwError(() => ({
|
||||
response: { status: 429, data: { message: 'Rate limited' } },
|
||||
}))
|
||||
);
|
||||
|
||||
await expect(service.getWeather('NYC')).rejects.toThrow(TooManyRequestsException);
|
||||
});
|
||||
});
|
||||
|
||||
// Mock repository instead of database
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let repo: jest.Mocked<Repository<User>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRepo = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepo }],
|
||||
}).compile();
|
||||
|
||||
service = module.get(UsersService);
|
||||
repo = module.get(getRepositoryToken(User));
|
||||
});
|
||||
|
||||
it('should find user by id', async () => {
|
||||
const mockUser = { id: '1', name: 'John', email: 'john@test.com' };
|
||||
repo.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.findById('1');
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(repo.findOne).toHaveBeenCalledWith({ where: { id: '1' } });
|
||||
});
|
||||
});
|
||||
|
||||
// Create mock factory for complex SDKs
|
||||
function createMockStripe(): jest.Mocked<Stripe> {
|
||||
return {
|
||||
paymentIntents: {
|
||||
create: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
confirm: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
},
|
||||
customers: {
|
||||
create: jest.fn(),
|
||||
retrieve: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
// Mock time for time-dependent tests
|
||||
describe('TokenService', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-01-15'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should expire token after 1 hour', async () => {
|
||||
const token = await service.createToken();
|
||||
|
||||
// Fast-forward time
|
||||
jest.advanceTimersByTime(61 * 60 * 1000);
|
||||
|
||||
expect(await service.isValid(token)).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [Jest Mocking](https://jestjs.io/docs/mock-functions)
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Use Testing Module for Unit Tests
|
||||
impact: HIGH
|
||||
impactDescription: Enables proper isolated testing with mocked dependencies
|
||||
tags: testing, unit-tests, mocking, jest
|
||||
---
|
||||
|
||||
## Use Testing Module for Unit Tests
|
||||
|
||||
Use `@nestjs/testing` module to create isolated test environments with mocked dependencies. This ensures your tests run fast, don't depend on external services, and properly test your business logic in isolation.
|
||||
|
||||
**Incorrect (manual instantiation bypassing DI):**
|
||||
|
||||
```typescript
|
||||
// Instantiate services manually without DI
|
||||
describe('UsersService', () => {
|
||||
it('should create user', async () => {
|
||||
// Manual instantiation bypasses DI
|
||||
const repo = new UserRepository(); // Real repo!
|
||||
const service = new UsersService(repo);
|
||||
|
||||
const user = await service.create({ name: 'Test' });
|
||||
// This hits the real database!
|
||||
});
|
||||
});
|
||||
|
||||
// Test implementation details
|
||||
describe('UsersController', () => {
|
||||
it('should call service', async () => {
|
||||
const service = { create: jest.fn() };
|
||||
const controller = new UsersController(service as any);
|
||||
|
||||
await controller.create({ name: 'Test' });
|
||||
|
||||
expect(service.create).toHaveBeenCalled(); // Tests implementation, not behavior
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Correct (use Test.createTestingModule with mocked dependencies):**
|
||||
|
||||
```typescript
|
||||
// Use Test.createTestingModule for proper DI
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
let repo: jest.Mocked<UserRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{
|
||||
provide: UserRepository,
|
||||
useValue: {
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
repo = module.get(UserRepository);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should save and return user', async () => {
|
||||
const dto = { name: 'John', email: 'john@test.com' };
|
||||
const expectedUser = { id: '1', ...dto };
|
||||
|
||||
repo.save.mockResolvedValue(expectedUser);
|
||||
|
||||
const result = await service.create(dto);
|
||||
|
||||
expect(result).toEqual(expectedUser);
|
||||
expect(repo.save).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('should throw on duplicate email', async () => {
|
||||
repo.findOne.mockResolvedValue({ id: '1', email: 'test@test.com' });
|
||||
|
||||
await expect(service.create({ name: 'Test', email: 'test@test.com' })).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return user when found', async () => {
|
||||
const user = { id: '1', name: 'John' };
|
||||
repo.findOne.mockResolvedValue(user);
|
||||
|
||||
const result = await service.findById('1');
|
||||
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when not found', async () => {
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findById('999')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Testing guards and interceptors
|
||||
describe('RolesGuard', () => {
|
||||
let guard: RolesGuard;
|
||||
let reflector: Reflector;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [RolesGuard, Reflector],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<RolesGuard>(RolesGuard);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
});
|
||||
|
||||
it('should allow when no roles required', () => {
|
||||
const context = createMockExecutionContext({ user: { roles: [] } });
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
|
||||
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow admin for admin-only route', () => {
|
||||
const context = createMockExecutionContext({ user: { roles: ['admin'] } });
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);
|
||||
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function createMockExecutionContext(request: Partial<Request>): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => request,
|
||||
}),
|
||||
getHandler: () => jest.fn(),
|
||||
getClass: () => jest.fn(),
|
||||
} as ExecutionContext;
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [NestJS Testing](https://docs.nestjs.com/fundamentals/testing)
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env npx ts-node
|
||||
|
||||
/**
|
||||
* Build script for generating AGENTS.md from individual rule files
|
||||
*
|
||||
* Usage: npx ts-node scripts/build-agents.ts
|
||||
*
|
||||
* This script:
|
||||
* 1. Reads all rule files from the rules/ directory
|
||||
* 2. Parses YAML frontmatter for metadata
|
||||
* 3. Groups rules by category based on filename prefix
|
||||
* 4. Generates a consolidated AGENTS.md file
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Category definitions with ordering and metadata
|
||||
const CATEGORIES = [
|
||||
{ prefix: 'arch-', name: 'Architecture', impact: 'CRITICAL', section: 1 },
|
||||
{ prefix: 'di-', name: 'Dependency Injection', impact: 'CRITICAL', section: 2 },
|
||||
{ prefix: 'error-', name: 'Error Handling', impact: 'HIGH', section: 3 },
|
||||
{ prefix: 'security-', name: 'Security', impact: 'HIGH', section: 4 },
|
||||
{ prefix: 'perf-', name: 'Performance', impact: 'HIGH', section: 5 },
|
||||
{ prefix: 'test-', name: 'Testing', impact: 'MEDIUM-HIGH', section: 6 },
|
||||
{ prefix: 'db-', name: 'Database & ORM', impact: 'MEDIUM-HIGH', section: 7 },
|
||||
{ prefix: 'api-', name: 'API Design', impact: 'MEDIUM', section: 8 },
|
||||
{ prefix: 'micro-', name: 'Microservices', impact: 'MEDIUM', section: 9 },
|
||||
{ prefix: 'devops-', name: 'DevOps & Deployment', impact: 'LOW-MEDIUM', section: 10 },
|
||||
{ prefix: 'lcbp3-', name: 'LCBP3 Project-Specific', impact: 'CRITICAL', section: 11 },
|
||||
];
|
||||
|
||||
interface RuleFrontmatter {
|
||||
title: string;
|
||||
impact: string;
|
||||
impactDescription: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
filename: string;
|
||||
frontmatter: RuleFrontmatter;
|
||||
content: string;
|
||||
category: string;
|
||||
categorySection: number;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): { frontmatter: RuleFrontmatter | null; body: string } {
|
||||
// Normalize CRLF → LF so the regex works on Windows-authored files
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||
const match = normalized.match(frontmatterRegex);
|
||||
|
||||
if (!match) {
|
||||
return { frontmatter: null, body: content };
|
||||
}
|
||||
|
||||
const frontmatterStr = match[1];
|
||||
const body = match[2];
|
||||
|
||||
// Simple YAML parsing for our expected format
|
||||
const frontmatter: Partial<RuleFrontmatter> = {};
|
||||
const lines = frontmatterStr.split('\n');
|
||||
let currentKey = '';
|
||||
let inArray = false;
|
||||
const arrayItems: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.match(/^[a-zA-Z]+:/)) {
|
||||
// Save previous array if we were collecting one
|
||||
if (inArray && currentKey === 'tags') {
|
||||
frontmatter.tags = arrayItems;
|
||||
}
|
||||
inArray = false;
|
||||
arrayItems.length = 0;
|
||||
|
||||
const [key, ...valueParts] = line.split(':');
|
||||
const value = valueParts.join(':').trim();
|
||||
currentKey = key.trim();
|
||||
|
||||
if (value === '') {
|
||||
// Might be start of array
|
||||
inArray = true;
|
||||
} else {
|
||||
(frontmatter as any)[currentKey] = value;
|
||||
}
|
||||
} else if (inArray && line.trim().startsWith('-')) {
|
||||
arrayItems.push(line.trim().replace(/^-\s*/, ''));
|
||||
}
|
||||
}
|
||||
|
||||
// Save final array if needed
|
||||
if (inArray && currentKey === 'tags') {
|
||||
frontmatter.tags = arrayItems;
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: frontmatter as RuleFrontmatter,
|
||||
body: body.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function getCategoryForFile(filename: string): { name: string; section: number } | null {
|
||||
for (const cat of CATEGORIES) {
|
||||
if (filename.startsWith(cat.prefix)) {
|
||||
return { name: cat.name, section: cat.section };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMetadata(): any {
|
||||
const metadataPath = path.join(__dirname, '..', 'metadata.json');
|
||||
return JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
||||
}
|
||||
|
||||
function readRules(): Rule[] {
|
||||
const rulesDir = path.join(__dirname, '..', 'rules');
|
||||
const files = fs.readdirSync(rulesDir).filter((f) => f.endsWith('.md') && !f.startsWith('_'));
|
||||
|
||||
const rules: Rule[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(rulesDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
if (!frontmatter) {
|
||||
console.warn(`Warning: No frontmatter found in ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const category = getCategoryForFile(file);
|
||||
if (!category) {
|
||||
console.warn(`Warning: Unknown category for ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
filename: file,
|
||||
frontmatter,
|
||||
content: body,
|
||||
category: category.name,
|
||||
categorySection: category.section,
|
||||
});
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function generateTableOfContents(rulesByCategory: Map<string, Rule[]>): string {
|
||||
let toc = '## Table of Contents\n\n';
|
||||
|
||||
for (const cat of CATEGORIES) {
|
||||
const rules = rulesByCategory.get(cat.name);
|
||||
if (!rules || rules.length === 0) continue;
|
||||
|
||||
// Section anchor format: #1-architecture
|
||||
const sectionAnchor = `${cat.section}-${cat.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
toc += `${cat.section}. [${cat.name}](#${sectionAnchor}) — **${cat.impact}**\n`;
|
||||
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i];
|
||||
// Rule anchor format: #11-rule-title
|
||||
const ruleNum = `${cat.section}${i + 1}`;
|
||||
const anchor = `${ruleNum}-${rule.frontmatter.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
toc += ` - ${cat.section}.${i + 1} [${rule.frontmatter.title}](#${anchor})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return toc;
|
||||
}
|
||||
|
||||
function generateAgentsMd(rules: Rule[], metadata: any): string {
|
||||
// Group rules by category
|
||||
const rulesByCategory = new Map<string, Rule[]>();
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rulesByCategory.has(rule.category)) {
|
||||
rulesByCategory.set(rule.category, []);
|
||||
}
|
||||
rulesByCategory.get(rule.category)!.push(rule);
|
||||
}
|
||||
|
||||
// Sort rules within each category alphabetically
|
||||
for (const [category, categoryRules] of rulesByCategory) {
|
||||
categoryRules.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
}
|
||||
|
||||
// Build document
|
||||
let doc = `# NestJS Best Practices
|
||||
|
||||
**Version ${metadata.version}**
|
||||
${metadata.organization}
|
||||
${metadata.date}
|
||||
|
||||
> **Note:**
|
||||
> This document is mainly for agents and LLMs to follow when maintaining,
|
||||
> generating, or refactoring NestJS codebases. Humans may also find it
|
||||
> useful, but guidance here is optimized for automation and consistency
|
||||
> by AI-assisted workflows.
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
${metadata.abstract}
|
||||
|
||||
---
|
||||
|
||||
`;
|
||||
|
||||
// Add table of contents
|
||||
doc += generateTableOfContents(rulesByCategory);
|
||||
doc += '\n---\n\n';
|
||||
|
||||
// Add rules by category
|
||||
for (const cat of CATEGORIES) {
|
||||
const categoryRules = rulesByCategory.get(cat.name);
|
||||
if (!categoryRules || categoryRules.length === 0) continue;
|
||||
|
||||
doc += `## ${cat.section}. ${cat.name}\n\n`;
|
||||
doc += `**Section Impact: ${cat.impact}**\n\n`;
|
||||
|
||||
for (let i = 0; i < categoryRules.length; i++) {
|
||||
const rule = categoryRules[i];
|
||||
const ruleNumber = `${cat.section}.${i + 1}`;
|
||||
|
||||
// Add rule header with number (anchor will be auto-generated as #11-title)
|
||||
doc += `### ${ruleNumber} ${rule.frontmatter.title}\n\n`;
|
||||
doc += `**Impact: ${rule.frontmatter.impact}** — ${rule.frontmatter.impactDescription}\n\n`;
|
||||
|
||||
// Add rule content (skip the first header since we already added it)
|
||||
let ruleContent = rule.content;
|
||||
// Remove the first h1 or h2 header if it matches the title
|
||||
ruleContent = ruleContent.replace(/^#{1,2}\s+.*\n+/, '');
|
||||
// Remove the impact line if present (we already added it)
|
||||
ruleContent = ruleContent.replace(/^\*\*Impact:.*\*\*.*\n+/, '');
|
||||
|
||||
doc += ruleContent;
|
||||
doc += '\n\n---\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
// Add references footer
|
||||
doc += `## References
|
||||
|
||||
`;
|
||||
for (const ref of metadata.references) {
|
||||
doc += `- ${ref}\n`;
|
||||
}
|
||||
|
||||
doc += `
|
||||
---
|
||||
|
||||
*Generated by build-agents.ts on ${new Date().toISOString().split('T')[0]}*
|
||||
`;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('Building AGENTS.md...\n');
|
||||
|
||||
const metadata = readMetadata();
|
||||
console.log(`Version: ${metadata.version}`);
|
||||
console.log(`Organization: ${metadata.organization}\n`);
|
||||
|
||||
const rules = readRules();
|
||||
console.log(`Found ${rules.length} rules\n`);
|
||||
|
||||
// Count by category
|
||||
const counts = new Map<string, number>();
|
||||
for (const rule of rules) {
|
||||
counts.set(rule.category, (counts.get(rule.category) || 0) + 1);
|
||||
}
|
||||
|
||||
console.log('Rules by category:');
|
||||
for (const cat of CATEGORIES) {
|
||||
const count = counts.get(cat.name) || 0;
|
||||
if (count > 0) {
|
||||
console.log(` ${cat.name}: ${count}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const agentsMd = generateAgentsMd(rules, metadata);
|
||||
|
||||
const outputPath = path.join(__dirname, '..', 'AGENTS.md');
|
||||
fs.writeFileSync(outputPath, agentsMd);
|
||||
|
||||
console.log(`Generated AGENTS.md (${agentsMd.length} bytes)`);
|
||||
console.log(`Output: ${outputPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build script for generating AGENTS.md
|
||||
# Usage: ./build.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Check if ts-node is available
|
||||
if command -v npx &> /dev/null; then
|
||||
echo "Running build with ts-node..."
|
||||
npx ts-node build-agents.ts
|
||||
else
|
||||
echo "Error: npx not found. Please install Node.js."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "nestjs-best-practices-scripts",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Build scripts for NestJS Best Practices skillset",
|
||||
"scripts": {
|
||||
"build": "npx ts-node build-agents.ts",
|
||||
"build:watch": "npx nodemon --watch ../rules --ext md --exec 'npx ts-node build-agents.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.0.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
---
|
||||
name: next-best-practices
|
||||
description: Next.js best practices for LCBP3-DMS frontend. Enforces ADR-019 (publicId only, no parseInt/id fallback), TanStack Query + RHF + Zod, shadcn/ui, i18n, ADR-007 error UX, ADR-021 IntegratedBanner/WorkflowLifecycle, two-phase file upload.
|
||||
version: 1.9.0
|
||||
scope: frontend
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# Next.js Best Practices
|
||||
|
||||
Apply these rules when writing or reviewing Next.js code.
|
||||
|
||||
## File Conventions
|
||||
|
||||
See [file-conventions.md](./file-conventions.md) for:
|
||||
|
||||
- Project structure and special files
|
||||
- Route segments (dynamic, catch-all, groups)
|
||||
- Parallel and intercepting routes
|
||||
- Middleware rename in v16 (middleware → proxy)
|
||||
|
||||
## RSC Boundaries
|
||||
|
||||
Detect invalid React Server Component patterns.
|
||||
|
||||
See [rsc-boundaries.md](./rsc-boundaries.md) for:
|
||||
|
||||
- Async client component detection (invalid)
|
||||
- Non-serializable props detection
|
||||
- Server Action exceptions
|
||||
|
||||
## Async Patterns
|
||||
|
||||
Next.js 15+ async API changes.
|
||||
|
||||
See [async-patterns.md](./async-patterns.md) for:
|
||||
|
||||
- Async `params` and `searchParams`
|
||||
- Async `cookies()` and `headers()`
|
||||
- Migration codemod
|
||||
|
||||
## Runtime Selection
|
||||
|
||||
See [runtime-selection.md](./runtime-selection.md) for:
|
||||
|
||||
- Default to Node.js runtime
|
||||
- When Edge runtime is appropriate
|
||||
|
||||
## Directives
|
||||
|
||||
See [directives.md](./directives.md) for:
|
||||
|
||||
- `'use client'`, `'use server'` (React)
|
||||
- `'use cache'` (Next.js)
|
||||
|
||||
## Functions
|
||||
|
||||
See [functions.md](./functions.md) for:
|
||||
|
||||
- Navigation hooks: `useRouter`, `usePathname`, `useSearchParams`, `useParams`
|
||||
- Server functions: `cookies`, `headers`, `draftMode`, `after`
|
||||
- Generate functions: `generateStaticParams`, `generateMetadata`
|
||||
|
||||
## Error Handling
|
||||
|
||||
See [error-handling.md](./error-handling.md) for:
|
||||
|
||||
- `error.tsx`, `global-error.tsx`, `not-found.tsx`
|
||||
- `redirect`, `permanentRedirect`, `notFound`
|
||||
- `forbidden`, `unauthorized` (auth errors)
|
||||
- `unstable_rethrow` for catch blocks
|
||||
|
||||
## Data Patterns
|
||||
|
||||
Project-specific: See [uuid-handling.md](./uuid-handling.md) for ADR-019 UUID handling patterns.
|
||||
|
||||
See [data-patterns.md](./data-patterns.md) for:
|
||||
|
||||
- Server Components vs Server Actions vs Route Handlers
|
||||
- Avoiding data waterfalls (`Promise.all`, Suspense, preload)
|
||||
- Client component data fetching
|
||||
|
||||
## Route Handlers
|
||||
|
||||
See [route-handlers.md](./route-handlers.md) for:
|
||||
|
||||
- `route.ts` basics
|
||||
- GET handler conflicts with `page.tsx`
|
||||
- Environment behavior (no React DOM)
|
||||
- When to use vs Server Actions
|
||||
|
||||
## Metadata & OG Images
|
||||
|
||||
See [metadata.md](./metadata.md) for:
|
||||
|
||||
- Static and dynamic metadata
|
||||
- `generateMetadata` function
|
||||
- OG image generation with `next/og`
|
||||
- File-based metadata conventions
|
||||
|
||||
## Image Optimization
|
||||
|
||||
See [image.md](./image.md) for:
|
||||
|
||||
- Always use `next/image` over `<img>`
|
||||
- Remote images configuration
|
||||
- Responsive `sizes` attribute
|
||||
- Blur placeholders
|
||||
- Priority loading for LCP
|
||||
|
||||
## Font Optimization
|
||||
|
||||
See [font.md](./font.md) for:
|
||||
|
||||
- `next/font` setup
|
||||
- Google Fonts, local fonts
|
||||
- Tailwind CSS integration
|
||||
- Preloading subsets
|
||||
|
||||
## Bundling
|
||||
|
||||
See [bundling.md](./bundling.md) for:
|
||||
|
||||
- Server-incompatible packages
|
||||
- CSS imports (not link tags)
|
||||
- Polyfills (already included)
|
||||
- ESM/CommonJS issues
|
||||
- Bundle analysis
|
||||
|
||||
## Scripts
|
||||
|
||||
See [scripts.md](./scripts.md) for:
|
||||
|
||||
- `next/script` vs native script tags
|
||||
- Inline scripts need `id`
|
||||
- Loading strategies
|
||||
- Google Analytics with `@next/third-parties`
|
||||
|
||||
## Hydration Errors
|
||||
|
||||
See [hydration-error.md](./hydration-error.md) for:
|
||||
|
||||
- Common causes (browser APIs, dates, invalid HTML)
|
||||
- Debugging with error overlay
|
||||
- Fixes for each cause
|
||||
|
||||
## Suspense Boundaries
|
||||
|
||||
See [suspense-boundaries.md](./suspense-boundaries.md) for:
|
||||
|
||||
- CSR bailout with `useSearchParams` and `usePathname`
|
||||
- Which hooks require Suspense boundaries
|
||||
|
||||
## Parallel & Intercepting Routes
|
||||
|
||||
See [parallel-routes.md](./parallel-routes.md) for:
|
||||
|
||||
- Modal patterns with `@slot` and `(.)` interceptors
|
||||
- `default.tsx` for fallbacks
|
||||
- Closing modals correctly with `router.back()`
|
||||
|
||||
## i18n (Thai / English)
|
||||
|
||||
See [i18n.md](./i18n.md) for:
|
||||
|
||||
- `useTranslations('namespace')` pattern
|
||||
- Key naming (kebab-case, feature-namespaced)
|
||||
- When Zod messages stay inline vs i18n
|
||||
- Server-side `userMessage` passthrough
|
||||
|
||||
## Two-Phase File Upload
|
||||
|
||||
See [two-phase-upload.md](./two-phase-upload.md) for:
|
||||
|
||||
- `useDropzone` + `useMutation` hook
|
||||
- `tempFileIds` form-state pattern
|
||||
- Whitelist MIME / max-size (must mirror backend)
|
||||
- Clear-on-submit / expired-temp handling
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
See [self-hosting.md](./self-hosting.md) for:
|
||||
|
||||
- `output: 'standalone'` for Docker
|
||||
- Cache handlers for multi-instance ISR
|
||||
- What works vs needs extra setup
|
||||
|
||||
## NAP-DMS Project-Specific Rules (MUST FOLLOW)
|
||||
|
||||
These rules are mandatory for the NAP-DMS LCBP3 frontend project:
|
||||
|
||||
### State Management (บังคับใช้)
|
||||
|
||||
**Server State - TanStack Query (React Query)**
|
||||
|
||||
```tsx
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// ❌ ห้ามใช้ useEffect โดยตรง
|
||||
// ✅ ใช้ TanStack Query
|
||||
export function useCorrespondences(projectId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['correspondences', projectId],
|
||||
queryFn: () => correspondenceService.getAll(projectId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Form State - React Hook Form + Zod**
|
||||
|
||||
```tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, 'กรุณาระบุหัวเรื่อง'),
|
||||
projectUuid: z.string().uuid('กรุณาเลือกโปรเจกต์'),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
```
|
||||
|
||||
### ADR-019 UUID Handling (CRITICAL — March 2026 Pattern)
|
||||
|
||||
> **Updated:** ใช้ `publicId` ตรงๆ — ห้ามใช้ `id ?? ''` fallback หรือ `uuid` ร่วม.
|
||||
|
||||
```tsx
|
||||
// ✅ CORRECT — Interface มีแค่ publicId
|
||||
interface Contract {
|
||||
publicId?: string; // UUID from API — ใช้ตัวนี้
|
||||
contractCode: string;
|
||||
contractName: string;
|
||||
}
|
||||
|
||||
// ✅ CORRECT — Select options (ไม่มี fallback)
|
||||
const options = contracts.map((c) => ({
|
||||
label: `${c.contractName} (${c.contractCode})`,
|
||||
value: c.publicId ?? '', // ใช้ publicId ล้วน
|
||||
key: c.publicId ?? c.contractCode, // fallback ไป business field ได้
|
||||
}));
|
||||
|
||||
// ❌ WRONG — pattern เก่า (ห้าม)
|
||||
interface OldContract {
|
||||
id?: number; // ❌ อย่า expose INT id
|
||||
uuid?: string; // ❌ ใช้ชื่อ uuid
|
||||
publicId?: string;
|
||||
}
|
||||
const oldValue = String(c.publicId ?? c.id ?? ''); // ❌ `id ?? ''` fallback ห้าม
|
||||
|
||||
// ❌ NEVER parseInt on UUID
|
||||
// const badId = parseInt(projectPublicId); // "019505..." → 19 (WRONG!)
|
||||
|
||||
// ✅ ส่ง UUID string ตรงๆ ไป API
|
||||
apiClient.get(`/projects/${projectPublicId}`);
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
**Code Identifiers - ภาษาอังกฤษ**
|
||||
|
||||
```tsx
|
||||
// ✅ Correct
|
||||
interface Correspondence {
|
||||
documentNumber: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ❌ Wrong
|
||||
interface เอกสาร {
|
||||
เลขที่: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Comments - ภาษาไทย**
|
||||
|
||||
```tsx
|
||||
// ✅ Correct - อธิบาย logic เป็นภาษาไทย
|
||||
// ตรวจสอบว่ามีการระบุ projectUuid หรือไม่
|
||||
if (!data.projectUuid) {
|
||||
throw new Error('กรุณาเลือกโปรเจกต์');
|
||||
}
|
||||
|
||||
// ❌ Wrong - ห้ามใช้ภาษาอังกฤษใน comments
|
||||
// Check if projectUuid is provided
|
||||
```
|
||||
|
||||
### UI Components
|
||||
|
||||
**บังคับใช้ shadcn/ui**
|
||||
|
||||
```tsx
|
||||
// ✅ Correct
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
// ❌ Wrong - ไม่สร้าง component เองถ้ามีใน shadcn
|
||||
const MyButton = () => <button className="...">Click</button>;
|
||||
```
|
||||
|
||||
### File Upload Pattern
|
||||
|
||||
```tsx
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
// Two-phase upload
|
||||
const onDrop = useCallback(async (files: File[]) => {
|
||||
// Phase 1: Upload to temp
|
||||
const tempFiles = await Promise.all(files.map((file) => uploadService.uploadTemp(file)));
|
||||
setTempIds(tempFiles.map((f) => f.tempId));
|
||||
}, []);
|
||||
|
||||
// Phase 2: Commit on form submit
|
||||
const onSubmit = async (data: FormData) => {
|
||||
await correspondenceService.create({
|
||||
...data,
|
||||
tempFileIds,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### API Client Setup
|
||||
|
||||
```typescript
|
||||
// lib/api/client.ts
|
||||
const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Auto-add Idempotency-Key
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
if (['post', 'put', 'patch'].includes(config.method?.toLowerCase() || '')) {
|
||||
config.headers['Idempotency-Key'] = uuidv4();
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
### Anti-Patterns (ห้ามทำ)
|
||||
|
||||
- ❌ Fetch data ใน useEffect โดยตรง (ใช้ TanStack Query)
|
||||
- ❌ Props drilling ลึกเกิน 3 levels
|
||||
- ❌ Inline styles (ใช้ Tailwind)
|
||||
- ❌ `console.log` ใน committed code
|
||||
- ❌ `parseInt()` / `Number()` / `+` บน UUID values (ADR-019)
|
||||
- ❌ `id ?? ''` fallback บน `publicId` (ใช้ `publicId ?? ''` หรือ fallback ไป business field)
|
||||
- ❌ Expose `uuid` คู่กับ `publicId` ใน interface (ใช้ `publicId` อย่างเดียว)
|
||||
- ❌ ใช้ index เป็น key ใน list
|
||||
- ❌ Snake_case ใน form field names (ใช้ camelCase)
|
||||
- ❌ Hardcode Thai/English string ใน component (ใช้ i18n keys)
|
||||
- ❌ `any` type (strict mode)
|
||||
|
||||
---
|
||||
|
||||
See [debug-tricks.md](./debug-tricks.md) for:
|
||||
|
||||
- MCP endpoint for AI-assisted debugging
|
||||
- Rebuild specific routes with `--debug-build-paths`
|
||||
@@ -0,0 +1,84 @@
|
||||
# Async Patterns
|
||||
|
||||
In Next.js 15+, `params`, `searchParams`, `cookies()`, and `headers()` are asynchronous.
|
||||
|
||||
## Async Params and SearchParams
|
||||
|
||||
Always type them as `Promise<...>` and await them.
|
||||
|
||||
### Pages and Layouts
|
||||
|
||||
```tsx
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export default async function Page({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
}
|
||||
```
|
||||
|
||||
### Route Handlers
|
||||
|
||||
```tsx
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
}
|
||||
```
|
||||
|
||||
### SearchParams
|
||||
|
||||
```tsx
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ query?: string }>;
|
||||
};
|
||||
|
||||
export default async function Page({ params, searchParams }: Props) {
|
||||
const { slug } = await params;
|
||||
const { query } = await searchParams;
|
||||
}
|
||||
```
|
||||
|
||||
### Synchronous Components
|
||||
|
||||
Use `React.use()` for non-async components:
|
||||
|
||||
```tsx
|
||||
import { use } from 'react';
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export default function Page({ params }: Props) {
|
||||
const { slug } = use(params);
|
||||
}
|
||||
```
|
||||
|
||||
### generateMetadata
|
||||
|
||||
```tsx
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
return { title: slug };
|
||||
}
|
||||
```
|
||||
|
||||
## Async Cookies and Headers
|
||||
|
||||
```tsx
|
||||
import { cookies, headers } from 'next/headers';
|
||||
|
||||
export default async function Page() {
|
||||
const cookieStore = await cookies();
|
||||
const headersList = await headers();
|
||||
|
||||
const theme = cookieStore.get('theme');
|
||||
const userAgent = headersList.get('user-agent');
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Codemod
|
||||
|
||||
```bash
|
||||
npx @next/codemod@latest next-async-request-api .
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# Bundling
|
||||
|
||||
Fix common bundling issues with third-party packages.
|
||||
|
||||
## Server-Incompatible Packages
|
||||
|
||||
Some packages use browser APIs (`window`, `document`, `localStorage`) and fail in Server Components.
|
||||
|
||||
### Error Signs
|
||||
|
||||
```
|
||||
ReferenceError: window is not defined
|
||||
ReferenceError: document is not defined
|
||||
ReferenceError: localStorage is not defined
|
||||
Module not found: Can't resolve 'fs'
|
||||
```
|
||||
|
||||
### Solution 1: Mark as Client-Only
|
||||
|
||||
If the package is only needed on client:
|
||||
|
||||
```tsx
|
||||
// Bad: Fails - package uses window
|
||||
import SomeChart from 'some-chart-library';
|
||||
|
||||
export default function Page() {
|
||||
return <SomeChart />;
|
||||
}
|
||||
|
||||
// Good: Use dynamic import with ssr: false
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const SomeChart = dynamic(() => import('some-chart-library'), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
return <SomeChart />;
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: Externalize from Server Bundle
|
||||
|
||||
For packages that should run on server but have bundling issues:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
serverExternalPackages: ['problematic-package'],
|
||||
};
|
||||
```
|
||||
|
||||
Use this for:
|
||||
|
||||
- Packages with native bindings (sharp, bcrypt)
|
||||
- Packages that don't bundle well (some ORMs)
|
||||
- Packages with circular dependencies
|
||||
|
||||
### Solution 3: Client Component Wrapper
|
||||
|
||||
Wrap the entire usage in a client component:
|
||||
|
||||
```tsx
|
||||
// components/ChartWrapper.tsx
|
||||
'use client';
|
||||
|
||||
import { Chart } from 'chart-library';
|
||||
|
||||
export function ChartWrapper(props) {
|
||||
return <Chart {...props} />;
|
||||
}
|
||||
|
||||
// app/page.tsx (server component)
|
||||
import { ChartWrapper } from '@/components/ChartWrapper';
|
||||
|
||||
export default function Page() {
|
||||
return <ChartWrapper data={data} />;
|
||||
}
|
||||
```
|
||||
|
||||
## CSS Imports
|
||||
|
||||
Import CSS files instead of using `<link>` tags. Next.js handles bundling and optimization.
|
||||
|
||||
```tsx
|
||||
// Bad: Manual link tag
|
||||
<link rel="stylesheet" href="/styles.css" />;
|
||||
|
||||
// Good: Import CSS
|
||||
import './styles.css';
|
||||
|
||||
// Good: CSS Modules
|
||||
import styles from './Button.module.css';
|
||||
```
|
||||
|
||||
## Polyfills
|
||||
|
||||
Next.js includes common polyfills automatically. Don't load redundant ones from polyfill.io or similar CDNs.
|
||||
|
||||
Already included: `Array.from`, `Object.assign`, `Promise`, `fetch`, `Map`, `Set`, `Symbol`, `URLSearchParams`, and 50+ others.
|
||||
|
||||
```tsx
|
||||
// Bad: Redundant polyfills
|
||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=fetch,Promise,Array.from" />
|
||||
|
||||
// Good: Next.js includes these automatically
|
||||
```
|
||||
|
||||
## ESM/CommonJS Issues
|
||||
|
||||
### Error Signs
|
||||
|
||||
```
|
||||
SyntaxError: Cannot use import statement outside a module
|
||||
Error: require() of ES Module
|
||||
Module not found: ESM packages need to be imported
|
||||
```
|
||||
|
||||
### Solution: Transpile Package
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
transpilePackages: ['some-esm-package', 'another-package'],
|
||||
};
|
||||
```
|
||||
|
||||
## Common Problematic Packages
|
||||
|
||||
| Package | Issue | Solution |
|
||||
| --------------- | --------------- | --------------------------------------------------------------- |
|
||||
| `sharp` | Native bindings | `serverExternalPackages: ['sharp']` |
|
||||
| `bcrypt` | Native bindings | `serverExternalPackages: ['bcrypt']` or use `bcryptjs` |
|
||||
| `canvas` | Native bindings | `serverExternalPackages: ['canvas']` |
|
||||
| `recharts` | Uses window | `dynamic(() => import('recharts'), { ssr: false })` |
|
||||
| `react-quill` | Uses document | `dynamic(() => import('react-quill'), { ssr: false })` |
|
||||
| `mapbox-gl` | Uses window | `dynamic(() => import('mapbox-gl'), { ssr: false })` |
|
||||
| `monaco-editor` | Uses window | `dynamic(() => import('@monaco-editor/react'), { ssr: false })` |
|
||||
| `lottie-web` | Uses document | `dynamic(() => import('lottie-react'), { ssr: false })` |
|
||||
|
||||
## Bundle Analysis
|
||||
|
||||
Analyze bundle size with the built-in analyzer (Next.js 16.1+):
|
||||
|
||||
```bash
|
||||
next experimental-analyze
|
||||
```
|
||||
|
||||
This opens an interactive UI to:
|
||||
|
||||
- Filter by route, environment (client/server), and type
|
||||
- Inspect module sizes and import chains
|
||||
- View treemap visualization
|
||||
|
||||
Save output for comparison:
|
||||
|
||||
```bash
|
||||
next experimental-analyze --output
|
||||
# Output saved to .next/diagnostics/analyze
|
||||
```
|
||||
|
||||
Reference: https://nextjs.org/docs/app/guides/package-bundling
|
||||
|
||||
## Migrating from Webpack to Turbopack
|
||||
|
||||
Turbopack is the default bundler in Next.js 15+. If you have custom webpack config, migrate to Turbopack-compatible alternatives:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
// Good: Works with Turbopack
|
||||
serverExternalPackages: ['package'],
|
||||
transpilePackages: ['package'],
|
||||
|
||||
// Bad: Webpack-only - migrate away from this
|
||||
webpack: (config) => {
|
||||
// custom webpack config
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Reference: https://nextjs.org/docs/app/building-your-application/upgrading/from-webpack-to-turbopack
|
||||
@@ -0,0 +1,300 @@
|
||||
# Data Patterns
|
||||
|
||||
Choose the right data fetching pattern for each use case.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to fetch data?
|
||||
├── From a Server Component?
|
||||
│ └── Use: Fetch directly (no API needed)
|
||||
│
|
||||
├── From a Client Component?
|
||||
│ ├── Is it a mutation (POST/PUT/DELETE)?
|
||||
│ │ └── Use: Server Action
|
||||
│ └── Is it a read (GET)?
|
||||
│ └── Use: Route Handler OR pass from Server Component
|
||||
│
|
||||
├── Need external API access (webhooks, third parties)?
|
||||
│ └── Use: Route Handler
|
||||
│
|
||||
└── Need REST API for mobile app / external clients?
|
||||
└── Use: Route Handler
|
||||
```
|
||||
|
||||
## Pattern 1: Server Components (Preferred for Reads)
|
||||
|
||||
Fetch data directly in Server Components - no API layer needed.
|
||||
|
||||
```tsx
|
||||
// app/users/page.tsx
|
||||
async function UsersPage() {
|
||||
// Direct database access - no API round-trip
|
||||
const users = await db.user.findMany();
|
||||
|
||||
// Or fetch from external API
|
||||
const posts = await fetch('https://api.example.com/posts').then((r) => r.json());
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>{user.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- No API to maintain
|
||||
- No client-server waterfall
|
||||
- Secrets stay on server
|
||||
- Direct database access
|
||||
|
||||
## Pattern 2: Server Actions (Preferred for Mutations)
|
||||
|
||||
Server Actions are the recommended way to handle mutations.
|
||||
|
||||
```tsx
|
||||
// app/actions.ts
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function createPost(formData: FormData) {
|
||||
const title = formData.get('title') as string;
|
||||
|
||||
await db.post.create({ data: { title } });
|
||||
|
||||
revalidatePath('/posts');
|
||||
}
|
||||
|
||||
export async function deletePost(id: string) {
|
||||
await db.post.delete({ where: { id } });
|
||||
|
||||
revalidateTag('posts');
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/posts/new/page.tsx
|
||||
import { createPost } from '@/app/actions';
|
||||
|
||||
export default function NewPost() {
|
||||
return (
|
||||
<form action={createPost}>
|
||||
<input name="title" required />
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
|
||||
- End-to-end type safety
|
||||
- Progressive enhancement (works without JS)
|
||||
- Automatic request handling
|
||||
- Integrated with React transitions
|
||||
|
||||
**Constraints**:
|
||||
|
||||
- POST only (no GET caching semantics)
|
||||
- Internal use only (no external access)
|
||||
- Cannot return non-serializable data
|
||||
|
||||
## Pattern 3: Route Handlers (APIs)
|
||||
|
||||
Use Route Handlers when you need a REST API.
|
||||
|
||||
```tsx
|
||||
// app/api/posts/route.ts
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// GET is cacheable
|
||||
export async function GET(request: NextRequest) {
|
||||
const posts = await db.post.findMany();
|
||||
return NextResponse.json(posts);
|
||||
}
|
||||
|
||||
// POST for mutations
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const post = await db.post.create({ data: body });
|
||||
return NextResponse.json(post, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
**When to use**:
|
||||
|
||||
- External API access (mobile apps, third parties)
|
||||
- Webhooks from external services
|
||||
- GET endpoints that need HTTP caching
|
||||
- OpenAPI/Swagger documentation needed
|
||||
|
||||
**When NOT to use**:
|
||||
|
||||
- Internal data fetching (use Server Components)
|
||||
- Mutations from your UI (use Server Actions)
|
||||
|
||||
## Avoiding Data Waterfalls
|
||||
|
||||
### Problem: Sequential Fetches
|
||||
|
||||
```tsx
|
||||
// Bad: Sequential waterfalls
|
||||
async function Dashboard() {
|
||||
const user = await getUser(); // Wait...
|
||||
const posts = await getPosts(); // Then wait...
|
||||
const comments = await getComments(); // Then wait...
|
||||
|
||||
return <div>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 1: Parallel Fetching with Promise.all
|
||||
|
||||
```tsx
|
||||
// Good: Parallel fetching
|
||||
async function Dashboard() {
|
||||
const [user, posts, comments] = await Promise.all([getUser(), getPosts(), getComments()]);
|
||||
|
||||
return <div>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 2: Streaming with Suspense
|
||||
|
||||
```tsx
|
||||
// Good: Show content progressively
|
||||
import { Suspense } from 'react';
|
||||
|
||||
async function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback={<UserSkeleton />}>
|
||||
<UserSection />
|
||||
</Suspense>
|
||||
<Suspense fallback={<PostsSkeleton />}>
|
||||
<PostsSection />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function UserSection() {
|
||||
const user = await getUser(); // Fetches independently
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
|
||||
async function PostsSection() {
|
||||
const posts = await getPosts(); // Fetches independently
|
||||
return <PostList posts={posts} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Solution 3: Preload Pattern
|
||||
|
||||
```tsx
|
||||
// lib/data.ts
|
||||
import { cache } from 'react';
|
||||
|
||||
export const getUser = cache(async (id: string) => {
|
||||
return db.user.findUnique({ where: { id } });
|
||||
});
|
||||
|
||||
export const preloadUser = (id: string) => {
|
||||
void getUser(id); // Fire and forget
|
||||
};
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/user/[id]/page.tsx
|
||||
import { getUser, preloadUser } from '@/lib/data';
|
||||
|
||||
export default async function UserPage({ params }) {
|
||||
const { id } = await params;
|
||||
|
||||
// Start fetching early
|
||||
preloadUser(id);
|
||||
|
||||
// Do other work...
|
||||
|
||||
// Data likely ready by now
|
||||
const user = await getUser(id);
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Client Component Data Fetching
|
||||
|
||||
When Client Components need data:
|
||||
|
||||
### Option 1: Pass from Server Component (Preferred)
|
||||
|
||||
```tsx
|
||||
// Server Component
|
||||
async function Page() {
|
||||
const data = await fetchData();
|
||||
return <ClientComponent initialData={data} />;
|
||||
}
|
||||
|
||||
// Client Component
|
||||
('use client');
|
||||
function ClientComponent({ initialData }) {
|
||||
const [data, setData] = useState(initialData);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Fetch on Mount (When Necessary)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function ClientComponent() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/data')
|
||||
.then((r) => r.json())
|
||||
.then(setData);
|
||||
}, []);
|
||||
|
||||
if (!data) return <Loading />;
|
||||
return <div>{data.value}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Server Action for Reads (Works But Not Ideal)
|
||||
|
||||
Server Actions can be called from Client Components for reads, but this is not their intended purpose:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { getData } from './actions';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function ClientComponent() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getData().then(setData);
|
||||
}, []);
|
||||
|
||||
return <div>{data?.value}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Server Actions always use POST, so no HTTP caching. Prefer Route Handlers for cacheable reads.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Use Case | HTTP Method | Caching |
|
||||
| ---------------------- | --------------------------- | ----------- | -------------------- |
|
||||
| Server Component fetch | Internal reads | Any | Full Next.js caching |
|
||||
| Server Action | Mutations, form submissions | POST only | No |
|
||||
| Route Handler | External APIs, webhooks | Any | GET can be cached |
|
||||
| Client fetch to API | Client-side reads | Any | HTTP cache headers |
|
||||
@@ -0,0 +1,122 @@
|
||||
# Debug Tricks
|
||||
|
||||
Tricks to speed up debugging Next.js applications.
|
||||
|
||||
## MCP Endpoint (Dev Server)
|
||||
|
||||
Next.js exposes a `/_next/mcp` endpoint in development for AI-assisted debugging via MCP (Model Context Protocol).
|
||||
|
||||
- **Next.js 16+**: Enabled by default, use `next-devtools-mcp`
|
||||
- **Next.js < 16**: Requires `experimental.mcpServer: true` in next.config.js
|
||||
|
||||
Reference: https://nextjs.org/docs/app/guides/mcp
|
||||
|
||||
**Important**: Find the actual port of the running Next.js dev server (check terminal output or `package.json` scripts). Don't assume port 3000.
|
||||
|
||||
### Request Format
|
||||
|
||||
The endpoint uses JSON-RPC 2.0 over HTTP POST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:<port>/_next/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "<tool-name>",
|
||||
"arguments": {}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Available Tools
|
||||
|
||||
#### `get_errors`
|
||||
|
||||
Get current errors from dev server (build errors, runtime errors with source-mapped stacks):
|
||||
|
||||
```json
|
||||
{ "name": "get_errors", "arguments": {} }
|
||||
```
|
||||
|
||||
#### `get_routes`
|
||||
|
||||
Discover all routes by scanning filesystem:
|
||||
|
||||
```json
|
||||
{ "name": "get_routes", "arguments": {} }
|
||||
// Optional: { "name": "get_routes", "arguments": { "routerType": "app" } }
|
||||
```
|
||||
|
||||
Returns: `{ "appRouter": ["/", "/api/users/[id]", ...], "pagesRouter": [...] }`
|
||||
|
||||
#### `get_project_metadata`
|
||||
|
||||
Get project path and dev server URL:
|
||||
|
||||
```json
|
||||
{ "name": "get_project_metadata", "arguments": {} }
|
||||
```
|
||||
|
||||
Returns: `{ "projectPath": "/path/to/project", "devServerUrl": "http://localhost:3000" }`
|
||||
|
||||
#### `get_page_metadata`
|
||||
|
||||
Get runtime metadata about current page render (requires active browser session):
|
||||
|
||||
```json
|
||||
{ "name": "get_page_metadata", "arguments": {} }
|
||||
```
|
||||
|
||||
Returns segment trie data showing layouts, boundaries, and page components.
|
||||
|
||||
#### `get_logs`
|
||||
|
||||
Get path to Next.js development log file:
|
||||
|
||||
```json
|
||||
{ "name": "get_logs", "arguments": {} }
|
||||
```
|
||||
|
||||
Returns path to `<distDir>/logs/next-development.log`
|
||||
|
||||
#### `get_server_action_by_id`
|
||||
|
||||
Locate a Server Action by ID:
|
||||
|
||||
```json
|
||||
{ "name": "get_server_action_by_id", "arguments": { "actionId": "<action-id>" } }
|
||||
```
|
||||
|
||||
### Example: Get Errors
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:<port>/_next/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"get_errors","arguments":{}}}'
|
||||
```
|
||||
|
||||
## Rebuild Specific Routes (Next.js 16+)
|
||||
|
||||
Use `--debug-build-paths` to rebuild only specific routes instead of the entire app:
|
||||
|
||||
```bash
|
||||
# Rebuild a specific route
|
||||
next build --debug-build-paths "/dashboard"
|
||||
|
||||
# Rebuild routes matching a glob
|
||||
next build --debug-build-paths "/api/*"
|
||||
|
||||
# Dynamic routes
|
||||
next build --debug-build-paths "/blog/[slug]"
|
||||
```
|
||||
|
||||
Use this to:
|
||||
|
||||
- Quickly verify a build fix without full rebuild
|
||||
- Debug static generation issues for specific pages
|
||||
- Iterate faster on build errors
|
||||
@@ -0,0 +1,74 @@
|
||||
# Directives
|
||||
|
||||
## React Directives
|
||||
|
||||
These are React directives, not Next.js specific.
|
||||
|
||||
### `'use client'`
|
||||
|
||||
Marks a component as a Client Component. Required for:
|
||||
|
||||
- React hooks (`useState`, `useEffect`, etc.)
|
||||
- Event handlers (`onClick`, `onChange`)
|
||||
- Browser APIs (`window`, `localStorage`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button onClick={() => setCount(count + 1)}>{count}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
Reference: https://react.dev/reference/rsc/use-client
|
||||
|
||||
### `'use server'`
|
||||
|
||||
Marks a function as a Server Action. Can be passed to Client Components.
|
||||
|
||||
```tsx
|
||||
'use server';
|
||||
|
||||
export async function submitForm(formData: FormData) {
|
||||
// Runs on server
|
||||
}
|
||||
```
|
||||
|
||||
Or inline within a Server Component:
|
||||
|
||||
```tsx
|
||||
export default function Page() {
|
||||
async function submit() {
|
||||
'use server';
|
||||
// Runs on server
|
||||
}
|
||||
return <form action={submit}>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
Reference: https://react.dev/reference/rsc/use-server
|
||||
|
||||
---
|
||||
|
||||
## Next.js Directive
|
||||
|
||||
### `'use cache'`
|
||||
|
||||
Marks a function or component for caching. Part of Next.js Cache Components.
|
||||
|
||||
```tsx
|
||||
'use cache';
|
||||
|
||||
export async function getCachedData() {
|
||||
return await fetchData();
|
||||
}
|
||||
```
|
||||
|
||||
Requires `cacheComponents: true` in `next.config.ts`.
|
||||
|
||||
For detailed usage including cache profiles, `cacheLife()`, `cacheTag()`, and `updateTag()`, see the `next-cache-components` skill.
|
||||
|
||||
Reference: https://nextjs.org/docs/app/api-reference/directives/use-cache
|
||||
@@ -0,0 +1,216 @@
|
||||
# Error Handling
|
||||
|
||||
Handle errors gracefully in Next.js applications.
|
||||
|
||||
Reference: https://nextjs.org/docs/app/getting-started/error-handling
|
||||
|
||||
## Error Boundaries
|
||||
|
||||
### `error.tsx`
|
||||
|
||||
Catches errors in a route segment and its children:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Something went wrong!</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** `error.tsx` must be a Client Component.
|
||||
|
||||
### `global-error.tsx`
|
||||
|
||||
Catches errors in root layout:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<h2>Something went wrong!</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Must include `<html>` and `<body>` tags.
|
||||
|
||||
## Server Actions: Navigation API Gotcha
|
||||
|
||||
**Do NOT wrap navigation APIs in try-catch.** They throw special errors that Next.js handles internally.
|
||||
|
||||
Reference: https://nextjs.org/docs/app/api-reference/functions/redirect#behavior
|
||||
|
||||
```tsx
|
||||
'use server'
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
// Bad: try-catch catches the navigation "error"
|
||||
async function createPost(formData: FormData) {
|
||||
try {
|
||||
const post = await db.post.create({ ... })
|
||||
redirect(`/posts/${post.id}`) // This throws!
|
||||
} catch (error) {
|
||||
// redirect() throw is caught here - navigation fails!
|
||||
return { error: 'Failed to create post' }
|
||||
}
|
||||
}
|
||||
|
||||
// Good: Call navigation APIs outside try-catch
|
||||
async function createPost(formData: FormData) {
|
||||
let post
|
||||
try {
|
||||
post = await db.post.create({ ... })
|
||||
} catch (error) {
|
||||
return { error: 'Failed to create post' }
|
||||
}
|
||||
redirect(`/posts/${post.id}`) // Outside try-catch
|
||||
}
|
||||
|
||||
// Good: Re-throw navigation errors
|
||||
async function createPost(formData: FormData) {
|
||||
try {
|
||||
const post = await db.post.create({ ... })
|
||||
redirect(`/posts/${post.id}`)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
|
||||
throw error // Re-throw navigation errors
|
||||
}
|
||||
return { error: 'Failed to create post' }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Same applies to:
|
||||
|
||||
- `redirect()` - 307 temporary redirect
|
||||
- `permanentRedirect()` - 308 permanent redirect
|
||||
- `notFound()` - 404 not found
|
||||
- `forbidden()` - 403 forbidden
|
||||
- `unauthorized()` - 401 unauthorized
|
||||
|
||||
Use `unstable_rethrow()` to re-throw these errors in catch blocks:
|
||||
|
||||
```tsx
|
||||
import { unstable_rethrow } from 'next/navigation';
|
||||
|
||||
async function action() {
|
||||
try {
|
||||
// ...
|
||||
redirect('/success');
|
||||
} catch (error) {
|
||||
unstable_rethrow(error); // Re-throws Next.js internal errors
|
||||
return { error: 'Something went wrong' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Redirects
|
||||
|
||||
```tsx
|
||||
import { redirect, permanentRedirect } from 'next/navigation';
|
||||
|
||||
// 307 Temporary - use for most cases
|
||||
redirect('/new-path');
|
||||
|
||||
// 308 Permanent - use for URL migrations (cached by browsers)
|
||||
permanentRedirect('/new-url');
|
||||
```
|
||||
|
||||
## Auth Errors
|
||||
|
||||
Trigger auth-related error pages:
|
||||
|
||||
```tsx
|
||||
import { forbidden, unauthorized } from 'next/navigation';
|
||||
|
||||
async function Page() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session) {
|
||||
unauthorized(); // Renders unauthorized.tsx (401)
|
||||
}
|
||||
|
||||
if (!session.hasAccess) {
|
||||
forbidden(); // Renders forbidden.tsx (403)
|
||||
}
|
||||
|
||||
return <Dashboard />;
|
||||
}
|
||||
```
|
||||
|
||||
Create corresponding error pages:
|
||||
|
||||
```tsx
|
||||
// app/forbidden.tsx
|
||||
export default function Forbidden() {
|
||||
return <div>You don't have access to this resource</div>;
|
||||
}
|
||||
|
||||
// app/unauthorized.tsx
|
||||
export default function Unauthorized() {
|
||||
return <div>Please log in to continue</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Not Found
|
||||
|
||||
### `not-found.tsx`
|
||||
|
||||
Custom 404 page for a route segment:
|
||||
|
||||
```tsx
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Not Found</h2>
|
||||
<p>Could not find the requested resource</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Triggering Not Found
|
||||
|
||||
```tsx
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const post = await getPost(id);
|
||||
|
||||
if (!post) {
|
||||
notFound(); // Renders closest not-found.tsx
|
||||
}
|
||||
|
||||
return <div>{post.title}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Hierarchy
|
||||
|
||||
Errors bubble up to the nearest error boundary:
|
||||
|
||||
```
|
||||
app/
|
||||
├── error.tsx # Catches errors from all children
|
||||
├── blog/
|
||||
│ ├── error.tsx # Catches errors in /blog/*
|
||||
│ └── [slug]/
|
||||
│ ├── error.tsx # Catches errors in /blog/[slug]
|
||||
│ └── page.tsx
|
||||
└── layout.tsx # Errors here go to global-error.tsx
|
||||
```
|
||||
@@ -0,0 +1,141 @@
|
||||
# File Conventions
|
||||
|
||||
Next.js App Router uses file-based routing with special file conventions.
|
||||
|
||||
## Project Structure
|
||||
|
||||
Reference: https://nextjs.org/docs/app/getting-started/project-structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout (required)
|
||||
├── page.tsx # Home page (/)
|
||||
├── loading.tsx # Loading UI
|
||||
├── error.tsx # Error UI
|
||||
├── not-found.tsx # 404 UI
|
||||
├── global-error.tsx # Global error UI
|
||||
├── route.ts # API endpoint
|
||||
├── template.tsx # Re-rendered layout
|
||||
├── default.tsx # Parallel route fallback
|
||||
├── blog/
|
||||
│ ├── page.tsx # /blog
|
||||
│ └── [slug]/
|
||||
│ └── page.tsx # /blog/:slug
|
||||
└── (group)/ # Route group (no URL impact)
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
## Special Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| `page.tsx` | UI for a route segment |
|
||||
| `layout.tsx` | Shared UI for segment and children |
|
||||
| `loading.tsx` | Loading UI (Suspense boundary) |
|
||||
| `error.tsx` | Error UI (Error boundary) |
|
||||
| `not-found.tsx` | 404 UI |
|
||||
| `route.ts` | API endpoint |
|
||||
| `template.tsx` | Like layout but re-renders on navigation |
|
||||
| `default.tsx` | Fallback for parallel routes |
|
||||
|
||||
## Route Segments
|
||||
|
||||
```
|
||||
app/
|
||||
├── blog/ # Static segment: /blog
|
||||
├── [slug]/ # Dynamic segment: /:slug
|
||||
├── [...slug]/ # Catch-all: /a/b/c
|
||||
├── [[...slug]]/ # Optional catch-all: / or /a/b/c
|
||||
└── (marketing)/ # Route group (ignored in URL)
|
||||
```
|
||||
|
||||
## Parallel Routes
|
||||
|
||||
```
|
||||
app/
|
||||
├── @analytics/
|
||||
│ └── page.tsx
|
||||
├── @sidebar/
|
||||
│ └── page.tsx
|
||||
└── layout.tsx # Receives { analytics, sidebar } as props
|
||||
```
|
||||
|
||||
## Intercepting Routes
|
||||
|
||||
```
|
||||
app/
|
||||
├── feed/
|
||||
│ └── page.tsx
|
||||
├── @modal/
|
||||
│ └── (.)photo/[id]/ # Intercepts /photo/[id] from /feed
|
||||
│ └── page.tsx
|
||||
└── photo/[id]/
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
Conventions:
|
||||
|
||||
- `(.)` - same level
|
||||
- `(..)` - one level up
|
||||
- `(..)(..)` - two levels up
|
||||
- `(...)` - from root
|
||||
|
||||
## Private Folders
|
||||
|
||||
```
|
||||
app/
|
||||
├── _components/ # Private folder (not a route)
|
||||
│ └── Button.tsx
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
Prefix with `_` to exclude from routing.
|
||||
|
||||
## Middleware / Proxy
|
||||
|
||||
### Next.js 14-15: `middleware.ts`
|
||||
|
||||
```ts
|
||||
// middleware.ts (root of project)
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Auth, redirects, rewrites, etc.
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/api/:path*'],
|
||||
};
|
||||
```
|
||||
|
||||
### Next.js 16+: `proxy.ts`
|
||||
|
||||
Renamed for clarity - same capabilities, different names:
|
||||
|
||||
```ts
|
||||
// proxy.ts (root of project)
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
// Same logic as middleware
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const proxyConfig = {
|
||||
matcher: ['/dashboard/:path*', '/api/:path*'],
|
||||
};
|
||||
```
|
||||
|
||||
| Version | File | Export | Config |
|
||||
| ------- | --------------- | -------------- | ------------- |
|
||||
| v14-15 | `middleware.ts` | `middleware()` | `config` |
|
||||
| v16+ | `proxy.ts` | `proxy()` | `proxyConfig` |
|
||||
|
||||
**Migration**: Run `npx @next/codemod@latest upgrade` to auto-rename.
|
||||
|
||||
## File Conventions Reference
|
||||
|
||||
Reference: https://nextjs.org/docs/app/api-reference/file-conventions
|
||||
@@ -0,0 +1,246 @@
|
||||
# Font Optimization
|
||||
|
||||
Use `next/font` for automatic font optimization with zero layout shift.
|
||||
|
||||
## Google Fonts
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={inter.className}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Fonts
|
||||
|
||||
```tsx
|
||||
import { Inter, Roboto_Mono } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
const robotoMono = Roboto_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-roboto-mono',
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Use in CSS:
|
||||
|
||||
```css
|
||||
body {
|
||||
font-family: var(--font-inter);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-roboto-mono);
|
||||
}
|
||||
```
|
||||
|
||||
## Font Weights and Styles
|
||||
|
||||
```tsx
|
||||
// Single weight
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: '400',
|
||||
});
|
||||
|
||||
// Multiple weights
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '700'],
|
||||
});
|
||||
|
||||
// Variable font (recommended) - includes all weights
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
// No weight needed - variable fonts support all weights
|
||||
});
|
||||
|
||||
// With italic
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
style: ['normal', 'italic'],
|
||||
});
|
||||
```
|
||||
|
||||
## Local Fonts
|
||||
|
||||
```tsx
|
||||
import localFont from 'next/font/local';
|
||||
|
||||
const myFont = localFont({
|
||||
src: './fonts/MyFont.woff2',
|
||||
});
|
||||
|
||||
// Multiple files for different weights
|
||||
const myFont = localFont({
|
||||
src: [
|
||||
{
|
||||
path: './fonts/MyFont-Regular.woff2',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './fonts/MyFont-Bold.woff2',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Variable font
|
||||
const myFont = localFont({
|
||||
src: './fonts/MyFont-Variable.woff2',
|
||||
variable: '--font-my-font',
|
||||
});
|
||||
```
|
||||
|
||||
## Tailwind CSS Integration
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter',
|
||||
});
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" className={inter.variable}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['var(--font-inter)'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Preloading Subsets
|
||||
|
||||
Only load needed character subsets:
|
||||
|
||||
```tsx
|
||||
// Latin only (most common)
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
// Multiple subsets
|
||||
const inter = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] });
|
||||
```
|
||||
|
||||
## Display Strategy
|
||||
|
||||
Control font loading behavior:
|
||||
|
||||
```tsx
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
display: 'swap', // Default - shows fallback, swaps when loaded
|
||||
});
|
||||
|
||||
// Options:
|
||||
// 'auto' - browser decides
|
||||
// 'block' - short block period, then swap
|
||||
// 'swap' - immediate fallback, swap when ready (recommended)
|
||||
// 'fallback' - short block, short swap, then fallback
|
||||
// 'optional' - short block, no swap (use if font is optional)
|
||||
```
|
||||
|
||||
## Don't Use Manual Font Links
|
||||
|
||||
Always use `next/font` instead of `<link>` tags for Google Fonts.
|
||||
|
||||
```tsx
|
||||
// Bad: Manual link tag (blocks rendering, no optimization)
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
|
||||
|
||||
// Bad: Missing display and preconnect
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
|
||||
|
||||
// Good: Use next/font (self-hosted, zero layout shift)
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
```tsx
|
||||
// Bad: Importing font in every component
|
||||
// components/Button.tsx
|
||||
import { Inter } from 'next/font/google'
|
||||
const inter = Inter({ subsets: ['latin'] }) // Creates new instance each time!
|
||||
|
||||
// Good: Import once in layout, use CSS variable
|
||||
// app/layout.tsx
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
|
||||
|
||||
// Bad: Using @import in CSS (blocks rendering)
|
||||
/* globals.css */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter');
|
||||
|
||||
// Good: Use next/font (self-hosted, no network request)
|
||||
import { Inter } from 'next/font/google'
|
||||
|
||||
// Bad: Loading all weights when only using a few
|
||||
const inter = Inter({ subsets: ['latin'] }) // Loads all weights
|
||||
|
||||
// Good: Specify only needed weights (for non-variable fonts)
|
||||
const inter = Inter({ subsets: ['latin'], weight: ['400', '700'] })
|
||||
|
||||
// Bad: Missing subset - loads all characters
|
||||
const inter = Inter({})
|
||||
|
||||
// Good: Always specify subset
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
```
|
||||
|
||||
## Font in Specific Components
|
||||
|
||||
```tsx
|
||||
// For component-specific fonts, export from a shared file
|
||||
// lib/fonts.ts
|
||||
import { Inter, Playfair_Display } from 'next/font/google';
|
||||
|
||||
export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
|
||||
export const playfair = Playfair_Display({ subsets: ['latin'], variable: '--font-playfair' });
|
||||
|
||||
// components/Heading.tsx
|
||||
import { playfair } from '@/lib/fonts';
|
||||
|
||||
export function Heading({ children }) {
|
||||
return <h1 className={playfair.className}>{children}</h1>;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# Functions
|
||||
|
||||
Next.js function APIs.
|
||||
|
||||
Reference: https://nextjs.org/docs/app/api-reference/functions
|
||||
|
||||
## Navigation Hooks (Client)
|
||||
|
||||
| Hook | Purpose | Reference |
|
||||
| --------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `useRouter` | Programmatic navigation (`push`, `replace`, `back`, `refresh`) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-router) |
|
||||
| `usePathname` | Get current pathname | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-pathname) |
|
||||
| `useSearchParams` | Read URL search parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-search-params) |
|
||||
| `useParams` | Access dynamic route parameters | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-params) |
|
||||
| `useSelectedLayoutSegment` | Active child segment (one level) | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segment) |
|
||||
| `useSelectedLayoutSegments` | All active segments below layout | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-selected-layout-segments) |
|
||||
| `useLinkStatus` | Check link prefetch status | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-link-status) |
|
||||
| `useReportWebVitals` | Report Core Web Vitals metrics | [Docs](https://nextjs.org/docs/app/api-reference/functions/use-report-web-vitals) |
|
||||
|
||||
## Server Functions
|
||||
|
||||
| Function | Purpose | Reference |
|
||||
| ------------ | -------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `cookies` | Read/write cookies | [Docs](https://nextjs.org/docs/app/api-reference/functions/cookies) |
|
||||
| `headers` | Read request headers | [Docs](https://nextjs.org/docs/app/api-reference/functions/headers) |
|
||||
| `draftMode` | Enable preview of unpublished CMS content | [Docs](https://nextjs.org/docs/app/api-reference/functions/draft-mode) |
|
||||
| `after` | Run code after response finishes streaming | [Docs](https://nextjs.org/docs/app/api-reference/functions/after) |
|
||||
| `connection` | Wait for connection before dynamic rendering | [Docs](https://nextjs.org/docs/app/api-reference/functions/connection) |
|
||||
| `userAgent` | Parse User-Agent header | [Docs](https://nextjs.org/docs/app/api-reference/functions/userAgent) |
|
||||
|
||||
## Generate Functions
|
||||
|
||||
| Function | Purpose | Reference |
|
||||
| ----------------------- | --------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `generateStaticParams` | Pre-render dynamic routes at build time | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-static-params) |
|
||||
| `generateMetadata` | Dynamic metadata | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) |
|
||||
| `generateViewport` | Dynamic viewport config | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-viewport) |
|
||||
| `generateSitemaps` | Multiple sitemaps for large sites | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps) |
|
||||
| `generateImageMetadata` | Multiple OG images per route | [Docs](https://nextjs.org/docs/app/api-reference/functions/generate-image-metadata) |
|
||||
|
||||
## Request/Response
|
||||
|
||||
| Function | Purpose | Reference |
|
||||
| --------------- | ------------------------------ | -------------------------------------------------------------------------- |
|
||||
| `NextRequest` | Extended Request with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-request) |
|
||||
| `NextResponse` | Extended Response with helpers | [Docs](https://nextjs.org/docs/app/api-reference/functions/next-response) |
|
||||
| `ImageResponse` | Generate OG images | [Docs](https://nextjs.org/docs/app/api-reference/functions/image-response) |
|
||||
|
||||
## Common Examples
|
||||
|
||||
### Navigation
|
||||
|
||||
Use `next/link` for internal navigation instead of `<a>` tags.
|
||||
|
||||
```tsx
|
||||
// Bad: Plain anchor tag
|
||||
<a href="/about">About</a>;
|
||||
|
||||
// Good: Next.js Link
|
||||
import Link from 'next/link';
|
||||
|
||||
<Link href="/about">About</Link>;
|
||||
```
|
||||
|
||||
Active link styling:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function NavLink({ href, children }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<Link href={href} className={pathname === href ? 'active' : ''}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Static Generation
|
||||
|
||||
```tsx
|
||||
// app/blog/[slug]/page.tsx
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getPosts();
|
||||
return posts.map((post) => ({ slug: post.slug }));
|
||||
}
|
||||
```
|
||||
|
||||
### After Response
|
||||
|
||||
```tsx
|
||||
import { after } from 'next/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const data = await processRequest(request);
|
||||
|
||||
after(async () => {
|
||||
await logAnalytics(data);
|
||||
});
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# Hydration Errors
|
||||
|
||||
Diagnose and fix React hydration mismatch errors.
|
||||
|
||||
## Error Signs
|
||||
|
||||
- "Hydration failed because the initial UI does not match"
|
||||
- "Text content does not match server-rendered HTML"
|
||||
|
||||
## Debugging
|
||||
|
||||
In development, click the hydration error to see the server/client diff.
|
||||
|
||||
## Common Causes and Fixes
|
||||
|
||||
### Browser-only APIs
|
||||
|
||||
```tsx
|
||||
// Bad: Causes mismatch - window doesn't exist on server
|
||||
<div>{window.innerWidth}</div>;
|
||||
|
||||
// Good: Use client component with mounted check
|
||||
('use client');
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function ClientOnly({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
return mounted ? children : null;
|
||||
}
|
||||
```
|
||||
|
||||
### Date/Time Rendering
|
||||
|
||||
Server and client may be in different timezones:
|
||||
|
||||
```tsx
|
||||
// Bad: Causes mismatch
|
||||
<span>{new Date().toLocaleString()}</span>;
|
||||
|
||||
// Good: Render on client only
|
||||
('use client');
|
||||
const [time, setTime] = useState<string>();
|
||||
useEffect(() => setTime(new Date().toLocaleString()), []);
|
||||
```
|
||||
|
||||
### Random Values or IDs
|
||||
|
||||
```tsx
|
||||
// Bad: Random values differ between server and client
|
||||
<div id={Math.random().toString()}>
|
||||
|
||||
// Good: Use useId hook
|
||||
import { useId } from 'react'
|
||||
|
||||
function Input() {
|
||||
const id = useId()
|
||||
return <input id={id} />
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid HTML Nesting
|
||||
|
||||
```tsx
|
||||
// Bad: Invalid - div inside p
|
||||
<p><div>Content</div></p>
|
||||
|
||||
// Bad: Invalid - p inside p
|
||||
<p><p>Nested</p></p>
|
||||
|
||||
// Good: Valid nesting
|
||||
<div><p>Content</p></div>
|
||||
```
|
||||
|
||||
### Third-party Scripts
|
||||
|
||||
Scripts that modify DOM during hydration.
|
||||
|
||||
```tsx
|
||||
// Good: Use next/script with afterInteractive
|
||||
import Script from 'next/script';
|
||||
|
||||
export default function Page() {
|
||||
return <Script src="https://example.com/script.js" strategy="afterInteractive" />;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# i18n (Thai / English)
|
||||
|
||||
LCBP3 frontend **must not** hardcode Thai or English UI strings in components.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **All user-facing strings go through the i18n layer** (`next-intl` / `i18next` — check `frontend/package.json`).
|
||||
2. **Keys use kebab-case**, namespaced by feature:
|
||||
- `correspondence.list.title`
|
||||
- `correspondence.form.submit`
|
||||
- `common.actions.cancel`
|
||||
3. **Comments in code remain Thai** (business logic explanation); **only UI copy** goes through i18n.
|
||||
4. **Error messages** from backend (via ADR-007 `userMessage`) are already localized server-side — render them directly, don't translate client-side.
|
||||
|
||||
---
|
||||
|
||||
## ❌ Wrong
|
||||
|
||||
```tsx
|
||||
export function CorrespondenceHeader() {
|
||||
return <h1>รายการหนังสือติดต่อ</h1>; // ❌ hardcoded Thai
|
||||
}
|
||||
|
||||
toast.success('บันทึกสำเร็จ'); // ❌ hardcoded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Right
|
||||
|
||||
```tsx
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function CorrespondenceHeader() {
|
||||
const t = useTranslations('correspondence.list');
|
||||
return <h1>{t('title')}</h1>;
|
||||
}
|
||||
|
||||
toast.success(t('save.success'));
|
||||
```
|
||||
|
||||
Translation files:
|
||||
|
||||
```json
|
||||
// messages/th.json
|
||||
{
|
||||
"correspondence": {
|
||||
"list": { "title": "รายการหนังสือติดต่อ" },
|
||||
"save": { "success": "บันทึกสำเร็จ" }
|
||||
}
|
||||
}
|
||||
|
||||
// messages/en.json
|
||||
{
|
||||
"correspondence": {
|
||||
"list": { "title": "Correspondence List" },
|
||||
"save": { "success": "Saved successfully" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Zod Error Messages
|
||||
|
||||
Zod error messages shown in forms **do** stay in Thai inline (per `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md`), because they're schema-bound and rarely need translation. If dual-language support becomes required, wrap with an i18n-aware resolver:
|
||||
|
||||
```ts
|
||||
const schema = z.object({
|
||||
projectUuid: z.string().uuid(t('validation.project.required')),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- [i18n Guidelines](../../../specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md)
|
||||
- [Frontend Guidelines](../../../specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md)
|
||||
@@ -0,0 +1,173 @@
|
||||
# Image Optimization
|
||||
|
||||
Use `next/image` for automatic image optimization.
|
||||
|
||||
## Always Use next/image
|
||||
|
||||
```tsx
|
||||
// Bad: Avoid native img
|
||||
<img src="/hero.png" alt="Hero" />;
|
||||
|
||||
// Good: Use next/image
|
||||
import Image from 'next/image';
|
||||
<Image src="/hero.png" alt="Hero" width={800} height={400} />;
|
||||
```
|
||||
|
||||
## Required Props
|
||||
|
||||
Images need explicit dimensions to prevent layout shift:
|
||||
|
||||
```tsx
|
||||
// Local images - dimensions inferred automatically
|
||||
import heroImage from './hero.png'
|
||||
<Image src={heroImage} alt="Hero" />
|
||||
|
||||
// Remote images - must specify width/height
|
||||
<Image src="https://example.com/image.jpg" alt="Hero" width={800} height={400} />
|
||||
|
||||
// Or use fill for parent-relative sizing
|
||||
<div style={{ position: 'relative', width: '100%', height: 400 }}>
|
||||
<Image src="/hero.png" alt="Hero" fill style={{ objectFit: 'cover' }} />
|
||||
</div>
|
||||
```
|
||||
|
||||
## Remote Images Configuration
|
||||
|
||||
Remote domains must be configured in `next.config.js`:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'example.com',
|
||||
pathname: '/images/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.cdn.com', // Wildcard subdomain
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Responsive Images
|
||||
|
||||
Use `sizes` to tell the browser which size to download:
|
||||
|
||||
```tsx
|
||||
// Full-width hero
|
||||
<Image
|
||||
src="/hero.png"
|
||||
alt="Hero"
|
||||
fill
|
||||
sizes="100vw"
|
||||
/>
|
||||
|
||||
// Responsive grid (3 columns on desktop, 1 on mobile)
|
||||
<Image
|
||||
src="/card.png"
|
||||
alt="Card"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
|
||||
// Fixed sidebar image
|
||||
<Image
|
||||
src="/avatar.png"
|
||||
alt="Avatar"
|
||||
width={200}
|
||||
height={200}
|
||||
sizes="200px"
|
||||
/>
|
||||
```
|
||||
|
||||
## Blur Placeholder
|
||||
|
||||
Prevent layout shift with placeholders:
|
||||
|
||||
```tsx
|
||||
// Local images - automatic blur hash
|
||||
import heroImage from './hero.png'
|
||||
<Image src={heroImage} alt="Hero" placeholder="blur" />
|
||||
|
||||
// Remote images - provide blurDataURL
|
||||
<Image
|
||||
src="https://example.com/image.jpg"
|
||||
alt="Hero"
|
||||
width={800}
|
||||
height={400}
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
|
||||
/>
|
||||
|
||||
// Or use color placeholder
|
||||
<Image
|
||||
src="https://example.com/image.jpg"
|
||||
alt="Hero"
|
||||
width={800}
|
||||
height={400}
|
||||
placeholder="empty"
|
||||
style={{ backgroundColor: '#e0e0e0' }}
|
||||
/>
|
||||
```
|
||||
|
||||
## Priority Loading
|
||||
|
||||
Use `priority` for above-the-fold images (LCP):
|
||||
|
||||
```tsx
|
||||
// Hero image - loads immediately
|
||||
<Image src="/hero.png" alt="Hero" fill priority />
|
||||
|
||||
// Below-fold images - lazy loaded by default (no priority needed)
|
||||
<Image src="/card.png" alt="Card" width={400} height={300} />
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
```tsx
|
||||
// Bad: Missing sizes with fill - downloads largest image
|
||||
<Image src="/hero.png" alt="Hero" fill />
|
||||
|
||||
// Good: Add sizes for proper responsive behavior
|
||||
<Image src="/hero.png" alt="Hero" fill sizes="100vw" />
|
||||
|
||||
// Bad: Using width/height for aspect ratio only
|
||||
<Image src="/hero.png" alt="Hero" width={16} height={9} />
|
||||
|
||||
// Good: Use actual display dimensions or fill with sizes
|
||||
<Image src="/hero.png" alt="Hero" fill sizes="100vw" style={{ objectFit: 'cover' }} />
|
||||
|
||||
// Bad: Remote image without config
|
||||
<Image src="https://untrusted.com/image.jpg" alt="Image" width={400} height={300} />
|
||||
// Error: Invalid src prop, hostname not configured
|
||||
|
||||
// Good: Add hostname to next.config.js remotePatterns
|
||||
```
|
||||
|
||||
## Static Export
|
||||
|
||||
When using `output: 'export'`, use `unoptimized` or custom loader:
|
||||
|
||||
```tsx
|
||||
// Option 1: Disable optimization
|
||||
<Image src="/hero.png" alt="Hero" width={800} height={400} unoptimized />;
|
||||
|
||||
// Option 2: Global config
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
output: 'export',
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
// Option 3: Custom loader (Cloudinary, Imgix, etc.)
|
||||
const cloudinaryLoader = ({ src, width, quality }) => {
|
||||
return `https://res.cloudinary.com/demo/image/upload/w_${width},q_${quality || 75}/${src}`;
|
||||
};
|
||||
|
||||
<Image loader={cloudinaryLoader} src="sample.jpg" alt="Sample" width={800} height={400} />;
|
||||
```
|
||||
@@ -0,0 +1,293 @@
|
||||
# Metadata
|
||||
|
||||
Add SEO metadata to Next.js pages using the Metadata API.
|
||||
|
||||
## Important: Server Components Only
|
||||
|
||||
The `metadata` object and `generateMetadata` function are **only supported in Server Components**. They cannot be used in Client Components.
|
||||
|
||||
If the target page has `'use client'`:
|
||||
|
||||
1. Remove `'use client'` if possible, move client logic to child components
|
||||
2. Or extract metadata to a parent Server Component layout
|
||||
3. Or split the file: Server Component with metadata imports Client Components
|
||||
|
||||
## Static Metadata
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Title',
|
||||
description: 'Page description for search engines',
|
||||
};
|
||||
```
|
||||
|
||||
## Dynamic Metadata
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPost(slug);
|
||||
return { title: post.title, description: post.description };
|
||||
}
|
||||
```
|
||||
|
||||
## Avoid Duplicate Fetches
|
||||
|
||||
Use React `cache()` when the same data is needed for both metadata and page:
|
||||
|
||||
```tsx
|
||||
import { cache } from 'react';
|
||||
|
||||
export const getPost = cache(async (slug: string) => {
|
||||
return await db.posts.findFirst({ where: { slug } });
|
||||
});
|
||||
```
|
||||
|
||||
## Viewport
|
||||
|
||||
Separate from metadata for streaming support:
|
||||
|
||||
```tsx
|
||||
import type { Viewport } from 'next';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
themeColor: '#000000',
|
||||
};
|
||||
|
||||
// Or dynamic
|
||||
export function generateViewport({ params }): Viewport {
|
||||
return { themeColor: getThemeColor(params) };
|
||||
}
|
||||
```
|
||||
|
||||
## Title Templates
|
||||
|
||||
In root layout for consistent naming:
|
||||
|
||||
```tsx
|
||||
export const metadata: Metadata = {
|
||||
title: { default: 'Site Name', template: '%s | Site Name' },
|
||||
};
|
||||
```
|
||||
|
||||
## Metadata File Conventions
|
||||
|
||||
Reference: https://nextjs.org/docs/app/getting-started/project-structure#metadata-file-conventions
|
||||
|
||||
Place these files in `app/` directory (or route segments):
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------------- | --------------------------------------------- |
|
||||
| `favicon.ico` | Favicon |
|
||||
| `icon.png` / `icon.svg` | App icon |
|
||||
| `apple-icon.png` | Apple app icon |
|
||||
| `opengraph-image.png` | OG image |
|
||||
| `twitter-image.png` | Twitter card image |
|
||||
| `sitemap.ts` / `sitemap.xml` | Sitemap (use `generateSitemaps` for multiple) |
|
||||
| `robots.ts` / `robots.txt` | Robots directives |
|
||||
| `manifest.ts` / `manifest.json` | Web app manifest |
|
||||
|
||||
## SEO Best Practice: Static Files Are Often Enough
|
||||
|
||||
For most sites, **static metadata files provide excellent SEO coverage**:
|
||||
|
||||
```
|
||||
app/
|
||||
├── favicon.ico
|
||||
├── opengraph-image.png # Works for both OG and Twitter
|
||||
├── sitemap.ts
|
||||
├── robots.ts
|
||||
└── layout.tsx # With title/description metadata
|
||||
```
|
||||
|
||||
**Tips:**
|
||||
|
||||
- A single `opengraph-image.png` covers both Open Graph and Twitter (Twitter falls back to OG)
|
||||
- Static `title` and `description` in layout metadata is sufficient for most pages
|
||||
- Only use dynamic `generateMetadata` when content varies per page
|
||||
|
||||
---
|
||||
|
||||
# OG Image Generation
|
||||
|
||||
Generate dynamic Open Graph images using `next/og`.
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Use `next/og`** - not `@vercel/og` (it's built into Next.js)
|
||||
2. **No searchParams** - OG images can't access search params, use route params instead
|
||||
3. **Avoid Edge runtime** - Use default Node.js runtime
|
||||
|
||||
```tsx
|
||||
// Good
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
// Bad
|
||||
// import { ImageResponse } from '@vercel/og'
|
||||
// export const runtime = 'edge'
|
||||
```
|
||||
|
||||
## Basic OG Image
|
||||
|
||||
```tsx
|
||||
// app/opengraph-image.tsx
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const alt = 'Site Name';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
export default function Image() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
fontSize: 128,
|
||||
background: 'white',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Hello World
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic OG Image
|
||||
|
||||
```tsx
|
||||
// app/blog/[slug]/opengraph-image.tsx
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export const alt = 'Blog Post';
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = 'image/png';
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export default async function Image({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const post = await getPost(slug);
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
fontSize: 48,
|
||||
background: 'linear-gradient(to bottom, #1a1a1a, #333)',
|
||||
color: 'white',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 64, fontWeight: 'bold' }}>{post.title}</div>
|
||||
<div style={{ marginTop: 24, opacity: 0.8 }}>{post.description}</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Fonts
|
||||
|
||||
```tsx
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { join } from 'path';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export default async function Image() {
|
||||
const fontPath = join(process.cwd(), 'assets/fonts/Inter-Bold.ttf');
|
||||
const fontData = await readFile(fontPath);
|
||||
|
||||
return new ImageResponse(<div style={{ fontFamily: 'Inter', fontSize: 64 }}>Custom Font Text</div>, {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [{ name: 'Inter', data: fontData, style: 'normal' }],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## File Naming
|
||||
|
||||
- `opengraph-image.tsx` - Open Graph (Facebook, LinkedIn)
|
||||
- `twitter-image.tsx` - Twitter/X cards (optional, falls back to OG)
|
||||
|
||||
## Styling Notes
|
||||
|
||||
ImageResponse uses Flexbox layout:
|
||||
|
||||
- Use `display: 'flex'`
|
||||
- No CSS Grid support
|
||||
- Styles must be inline objects
|
||||
|
||||
## Multiple OG Images
|
||||
|
||||
Use `generateImageMetadata` for multiple images per route:
|
||||
|
||||
```tsx
|
||||
// app/blog/[slug]/opengraph-image.tsx
|
||||
import { ImageResponse } from 'next/og';
|
||||
|
||||
export async function generateImageMetadata({ params }) {
|
||||
const images = await getPostImages(params.slug);
|
||||
return images.map((img, idx) => ({
|
||||
id: idx,
|
||||
alt: img.alt,
|
||||
size: { width: 1200, height: 630 },
|
||||
contentType: 'image/png',
|
||||
}));
|
||||
}
|
||||
|
||||
export default async function Image({ params, id }) {
|
||||
const images = await getPostImages(params.slug);
|
||||
const image = images[id];
|
||||
return new ImageResponse(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Sitemaps
|
||||
|
||||
Use `generateSitemaps` for large sites:
|
||||
|
||||
```tsx
|
||||
// app/sitemap.ts
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export async function generateSitemaps() {
|
||||
// Return array of sitemap IDs
|
||||
return [{ id: 0 }, { id: 1 }, { id: 2 }];
|
||||
}
|
||||
|
||||
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
|
||||
const start = id * 50000;
|
||||
const end = start + 50000;
|
||||
const products = await getProducts(start, end);
|
||||
|
||||
return products.map((product) => ({
|
||||
url: `https://example.com/product/${product.id}`,
|
||||
lastModified: product.updatedAt,
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Generates `/sitemap/0.xml`, `/sitemap/1.xml`, etc.
|
||||
@@ -0,0 +1,280 @@
|
||||
# Parallel & Intercepting Routes
|
||||
|
||||
Parallel routes render multiple pages in the same layout. Intercepting routes show a different UI when navigating from within your app vs direct URL access. Together they enable modal patterns.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── @modal/ # Parallel route slot
|
||||
│ ├── default.tsx # Required! Returns null
|
||||
│ ├── (.)photos/ # Intercepts /photos/*
|
||||
│ │ └── [id]/
|
||||
│ │ └── page.tsx # Modal content
|
||||
│ └── [...]catchall/ # Optional: catch unmatched
|
||||
│ └── page.tsx
|
||||
├── photos/
|
||||
│ └── [id]/
|
||||
│ └── page.tsx # Full page (direct access)
|
||||
├── layout.tsx # Renders both children and @modal
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
## Step 1: Root Layout with Slot
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
export default function RootLayout({ children, modal }: { children: React.ReactNode; modal: React.ReactNode }) {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{children}
|
||||
{modal}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Step 2: Default File (Critical!)
|
||||
|
||||
**Every parallel route slot MUST have a `default.tsx`** to prevent 404s on hard navigation.
|
||||
|
||||
```tsx
|
||||
// app/@modal/default.tsx
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Without this file, refreshing any page will 404 because Next.js can't determine what to render in the `@modal` slot.
|
||||
|
||||
## Step 3: Intercepting Route (Modal)
|
||||
|
||||
The `(.)` prefix intercepts routes at the same level.
|
||||
|
||||
```tsx
|
||||
// app/@modal/(.)photos/[id]/page.tsx
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const photo = await getPhoto(id);
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<img src={photo.url} alt={photo.title} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Step 4: Full Page (Direct Access)
|
||||
|
||||
```tsx
|
||||
// app/photos/[id]/page.tsx
|
||||
export default async function PhotoPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const photo = await getPhoto(id);
|
||||
|
||||
return (
|
||||
<div className="full-page">
|
||||
<img src={photo.url} alt={photo.title} />
|
||||
<h1>{photo.title}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Modal Component with Correct Closing
|
||||
|
||||
**Critical: Use `router.back()` to close modals, NOT `router.push()` or `<Link>`.**
|
||||
|
||||
```tsx
|
||||
// components/modal.tsx
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export function Modal({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
router.back(); // Correct
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [router]);
|
||||
|
||||
// Close on overlay click
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
router.back(); // Correct
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||
<button
|
||||
onClick={() => router.back()} // Correct!
|
||||
className="absolute top-4 right-4"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Why NOT `router.push('/')` or `<Link href="/">`?
|
||||
|
||||
Using `push` or `Link` to "close" a modal:
|
||||
|
||||
1. Adds a new history entry (back button shows modal again)
|
||||
2. Doesn't properly clear the intercepted route
|
||||
3. Can cause the modal to flash or persist unexpectedly
|
||||
|
||||
`router.back()` correctly:
|
||||
|
||||
1. Removes the intercepted route from history
|
||||
2. Returns to the previous page
|
||||
3. Properly unmounts the modal
|
||||
|
||||
## Route Matcher Reference
|
||||
|
||||
Matchers match **route segments**, not filesystem paths:
|
||||
|
||||
| Matcher | Matches | Example |
|
||||
| ---------- | ------------- | --------------------------------------------------------------------- |
|
||||
| `(.)` | Same level | `@modal/(.)photos` intercepts `/photos` |
|
||||
| `(..)` | One level up | `@modal/(..)settings` from `/dashboard/@modal` intercepts `/settings` |
|
||||
| `(..)(..)` | Two levels up | Rarely used |
|
||||
| `(...)` | From root | `@modal/(...)photos` intercepts `/photos` from anywhere |
|
||||
|
||||
**Common mistake**: Thinking `(..)` means "parent folder" - it means "parent route segment".
|
||||
|
||||
## Handling Hard Navigation
|
||||
|
||||
When users directly visit `/photos/123` (bookmark, refresh, shared link):
|
||||
|
||||
- The intercepting route is bypassed
|
||||
- The full `photos/[id]/page.tsx` renders
|
||||
- Modal doesn't appear (expected behavior)
|
||||
|
||||
If you want the modal to appear on direct access too, you need additional logic:
|
||||
|
||||
```tsx
|
||||
// app/photos/[id]/page.tsx
|
||||
import { Modal } from '@/components/modal';
|
||||
|
||||
export default async function PhotoPage({ params }) {
|
||||
const { id } = await params;
|
||||
const photo = await getPhoto(id);
|
||||
|
||||
// Option: Render as modal on direct access too
|
||||
return (
|
||||
<Modal>
|
||||
<img src={photo.url} alt={photo.title} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
### 1. Missing `default.tsx` → 404 on Refresh
|
||||
|
||||
Every `@slot` folder needs a `default.tsx` that returns `null` (or appropriate content).
|
||||
|
||||
### 2. Modal Persists After Navigation
|
||||
|
||||
You're using `router.push()` instead of `router.back()`.
|
||||
|
||||
### 3. Nested Parallel Routes Need Defaults Too
|
||||
|
||||
If you have `@modal` inside a route group, each level needs its own `default.tsx`:
|
||||
|
||||
```
|
||||
app/
|
||||
├── (marketing)/
|
||||
│ ├── @modal/
|
||||
│ │ └── default.tsx # Needed!
|
||||
│ └── layout.tsx
|
||||
└── layout.tsx
|
||||
```
|
||||
|
||||
### 4. Intercepted Route Shows Wrong Content
|
||||
|
||||
Check your matcher:
|
||||
|
||||
- `(.)photos` intercepts `/photos` from the same route level
|
||||
- If your `@modal` is in `app/dashboard/@modal`, use `(.)photos` to intercept `/dashboard/photos`, not `/photos`
|
||||
|
||||
### 5. TypeScript Errors with `params`
|
||||
|
||||
In Next.js 15+, `params` is a Promise:
|
||||
|
||||
```tsx
|
||||
// Correct
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example: Photo Gallery Modal
|
||||
|
||||
```
|
||||
app/
|
||||
├── @modal/
|
||||
│ ├── default.tsx
|
||||
│ └── (.)photos/
|
||||
│ └── [id]/
|
||||
│ └── page.tsx
|
||||
├── photos/
|
||||
│ ├── page.tsx # Gallery grid
|
||||
│ └── [id]/
|
||||
│ └── page.tsx # Full photo page
|
||||
├── layout.tsx
|
||||
└── page.tsx
|
||||
```
|
||||
|
||||
Links in the gallery:
|
||||
|
||||
```tsx
|
||||
// app/photos/page.tsx
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function Gallery() {
|
||||
const photos = await getPhotos();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<Link key={photo.id} href={`/photos/${photo.id}`}>
|
||||
<img src={photo.thumbnail} alt={photo.title} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Clicking a photo → Modal opens (intercepted)
|
||||
Direct URL → Full page renders
|
||||
Refresh while modal open → Full page renders
|
||||
@@ -0,0 +1,143 @@
|
||||
# Route Handlers
|
||||
|
||||
Create API endpoints with `route.ts` files.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
// app/api/users/route.ts
|
||||
export async function GET() {
|
||||
const users = await getUsers();
|
||||
return Response.json(users);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const user = await createUser(body);
|
||||
return Response.json(user, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Methods
|
||||
|
||||
`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`
|
||||
|
||||
## GET Handler Conflicts with page.tsx
|
||||
|
||||
**A `route.ts` and `page.tsx` cannot coexist in the same folder.**
|
||||
|
||||
```
|
||||
app/
|
||||
├── api/
|
||||
│ └── users/
|
||||
│ └── route.ts # /api/users
|
||||
└── users/
|
||||
├── page.tsx # /users (page)
|
||||
└── route.ts # Warning: Conflicts with page.tsx!
|
||||
```
|
||||
|
||||
If you need both a page and an API at the same path, use different paths:
|
||||
|
||||
```
|
||||
app/
|
||||
├── users/
|
||||
│ └── page.tsx # /users (page)
|
||||
└── api/
|
||||
└── users/
|
||||
└── route.ts # /api/users (API)
|
||||
```
|
||||
|
||||
## Environment Behavior
|
||||
|
||||
Route handlers run in a **Server Component-like environment**:
|
||||
|
||||
- Yes: Can use `async/await`
|
||||
- Yes: Can access `cookies()`, `headers()`
|
||||
- Yes: Can use Node.js APIs
|
||||
- No: Cannot use React hooks
|
||||
- No: Cannot use React DOM APIs
|
||||
- No: Cannot use browser APIs
|
||||
|
||||
```tsx
|
||||
// Bad: This won't work - no React DOM in route handlers
|
||||
import { renderToString } from 'react-dom/server';
|
||||
|
||||
export async function GET() {
|
||||
const html = renderToString(<Component />); // Error!
|
||||
return new Response(html);
|
||||
}
|
||||
```
|
||||
|
||||
## Dynamic Route Handlers
|
||||
|
||||
```tsx
|
||||
// app/api/users/[id]/route.ts
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const user = await getUser(id);
|
||||
|
||||
if (!user) {
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(user);
|
||||
}
|
||||
```
|
||||
|
||||
## Request Helpers
|
||||
|
||||
```tsx
|
||||
export async function GET(request: Request) {
|
||||
// URL and search params
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
// Headers
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
// Cookies (Next.js helper)
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('token');
|
||||
|
||||
return Response.json({ query, token });
|
||||
}
|
||||
```
|
||||
|
||||
## Response Helpers
|
||||
|
||||
```tsx
|
||||
// JSON response
|
||||
return Response.json({ data });
|
||||
|
||||
// With status
|
||||
return Response.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
// With headers
|
||||
return Response.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': 'max-age=3600',
|
||||
},
|
||||
});
|
||||
|
||||
// Redirect
|
||||
return Response.redirect(new URL('/login', request.url));
|
||||
|
||||
// Stream
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
```
|
||||
|
||||
## When to Use Route Handlers vs Server Actions
|
||||
|
||||
| Use Case | Route Handlers | Server Actions |
|
||||
| ------------------------ | -------------- | -------------- |
|
||||
| Form submissions | No | Yes |
|
||||
| Data mutations from UI | No | Yes |
|
||||
| Third-party webhooks | Yes | No |
|
||||
| External API consumption | Yes | No |
|
||||
| Public REST API | Yes | No |
|
||||
| File uploads | Both work | Both work |
|
||||
|
||||
**Prefer Server Actions** for mutations triggered from your UI.
|
||||
**Use Route Handlers** for external integrations and public APIs.
|
||||
@@ -0,0 +1,160 @@
|
||||
# RSC Boundaries
|
||||
|
||||
Detect and prevent invalid patterns when crossing Server/Client component boundaries.
|
||||
|
||||
## Detection Rules
|
||||
|
||||
### 1. Async Client Components Are Invalid
|
||||
|
||||
Client components **cannot** be async functions. Only Server Components can be async.
|
||||
|
||||
**Detect:** File has `'use client'` AND component is `async function` or returns `Promise`
|
||||
|
||||
```tsx
|
||||
// Bad: async client component
|
||||
'use client';
|
||||
export default async function UserProfile() {
|
||||
const user = await getUser(); // Cannot await in client component
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
|
||||
// Good: Remove async, fetch data in parent server component
|
||||
// page.tsx (server component - no 'use client')
|
||||
export default async function Page() {
|
||||
const user = await getUser();
|
||||
return <UserProfile user={user} />;
|
||||
}
|
||||
|
||||
// UserProfile.tsx (client component)
|
||||
('use client');
|
||||
export function UserProfile({ user }: { user: User }) {
|
||||
return <div>{user.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Bad: async arrow function client component
|
||||
'use client';
|
||||
const Dashboard = async () => {
|
||||
const data = await fetchDashboard();
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
// Good: Fetch in server component, pass data down
|
||||
```
|
||||
|
||||
### 2. Non-Serializable Props to Client Components
|
||||
|
||||
Props passed from Server → Client must be JSON-serializable.
|
||||
|
||||
**Detect:** Server component passes these to a client component:
|
||||
|
||||
- Functions (except Server Actions with `'use server'`)
|
||||
- `Date` objects
|
||||
- `Map`, `Set`, `WeakMap`, `WeakSet`
|
||||
- Class instances
|
||||
- `Symbol` (unless globally registered)
|
||||
- Circular references
|
||||
|
||||
```tsx
|
||||
// Bad: Function prop
|
||||
// page.tsx (server)
|
||||
export default function Page() {
|
||||
const handleClick = () => console.log('clicked');
|
||||
return <ClientButton onClick={handleClick} />;
|
||||
}
|
||||
|
||||
// Good: Define function inside client component
|
||||
// ClientButton.tsx
|
||||
('use client');
|
||||
export function ClientButton() {
|
||||
const handleClick = () => console.log('clicked');
|
||||
return <button onClick={handleClick}>Click</button>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Bad: Date object (silently becomes string, then crashes)
|
||||
// page.tsx (server)
|
||||
export default async function Page() {
|
||||
const post = await getPost();
|
||||
return <PostCard createdAt={post.createdAt} />; // Date object
|
||||
}
|
||||
|
||||
// PostCard.tsx (client) - will crash on .getFullYear()
|
||||
('use client');
|
||||
export function PostCard({ createdAt }: { createdAt: Date }) {
|
||||
return <span>{createdAt.getFullYear()}</span>; // Runtime error!
|
||||
}
|
||||
|
||||
// Good: Serialize to string on server
|
||||
// page.tsx (server)
|
||||
export default async function Page() {
|
||||
const post = await getPost();
|
||||
return <PostCard createdAt={post.createdAt.toISOString()} />;
|
||||
}
|
||||
|
||||
// PostCard.tsx (client)
|
||||
('use client');
|
||||
export function PostCard({ createdAt }: { createdAt: string }) {
|
||||
const date = new Date(createdAt);
|
||||
return <span>{date.getFullYear()}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Bad: Class instance
|
||||
const user = new UserModel(data)
|
||||
<ClientProfile user={user} /> // Methods will be stripped
|
||||
|
||||
// Good: Pass plain object
|
||||
const user = await getUser()
|
||||
<ClientProfile user={{ id: user.id, name: user.name }} />
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Bad: Map/Set
|
||||
<ClientComponent items={new Map([['a', 1]])} />
|
||||
|
||||
// Good: Convert to array/object
|
||||
<ClientComponent items={Object.fromEntries(map)} />
|
||||
<ClientComponent items={Array.from(set)} />
|
||||
```
|
||||
|
||||
### 3. Server Actions Are the Exception
|
||||
|
||||
Functions marked with `'use server'` CAN be passed to client components.
|
||||
|
||||
```tsx
|
||||
// Valid: Server Action can be passed
|
||||
// actions.ts
|
||||
'use server';
|
||||
export async function submitForm(formData: FormData) {
|
||||
// server-side logic
|
||||
}
|
||||
|
||||
// page.tsx (server)
|
||||
import { submitForm } from './actions';
|
||||
export default function Page() {
|
||||
return <ClientForm onSubmit={submitForm} />; // OK!
|
||||
}
|
||||
|
||||
// ClientForm.tsx (client)
|
||||
('use client');
|
||||
export function ClientForm({ onSubmit }: { onSubmit: (data: FormData) => Promise<void> }) {
|
||||
return <form action={onSubmit}>...</form>;
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Valid? | Fix |
|
||||
| --------------------------------- | ------ | ------------------------------------- |
|
||||
| `'use client'` + `async function` | No | Fetch in server parent, pass data |
|
||||
| Pass `() => {}` to client | No | Define in client or use server action |
|
||||
| Pass `new Date()` to client | No | Use `.toISOString()` |
|
||||
| Pass `new Map()` to client | No | Convert to object/array |
|
||||
| Pass class instance to client | No | Pass plain object |
|
||||
| Pass server action to client | Yes | - |
|
||||
| Pass `string/number/boolean` | Yes | - |
|
||||
| Pass plain object/array | Yes | - |
|
||||
@@ -0,0 +1,40 @@
|
||||
# Runtime Selection
|
||||
|
||||
## Use Node.js Runtime by Default
|
||||
|
||||
Use the default Node.js runtime for new routes and pages. Only use Edge runtime if the project already uses it or there's a specific requirement.
|
||||
|
||||
```tsx
|
||||
// Good: Default - no runtime config needed (uses Node.js)
|
||||
export default function Page() { ... }
|
||||
|
||||
// Caution: Only if already used in project or specifically required
|
||||
export const runtime = 'edge'
|
||||
```
|
||||
|
||||
## When to Use Each
|
||||
|
||||
### Node.js Runtime (Default)
|
||||
|
||||
- Full Node.js API support
|
||||
- File system access (`fs`)
|
||||
- Full `crypto` support
|
||||
- Database connections
|
||||
- Most npm packages work
|
||||
|
||||
### Edge Runtime
|
||||
|
||||
- Only for specific edge-location latency requirements
|
||||
- Limited API (no `fs`, limited `crypto`)
|
||||
- Smaller cold start
|
||||
- Geographic distribution needs
|
||||
|
||||
## Detection
|
||||
|
||||
**Before adding `runtime = 'edge'`**, check:
|
||||
|
||||
1. Does the project already use Edge runtime?
|
||||
2. Is there a specific latency requirement?
|
||||
3. Are all dependencies Edge-compatible?
|
||||
|
||||
If unsure, use Node.js runtime.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Scripts
|
||||
|
||||
Loading third-party scripts in Next.js.
|
||||
|
||||
## Use next/script
|
||||
|
||||
Always use `next/script` instead of native `<script>` tags for better performance.
|
||||
|
||||
```tsx
|
||||
// Bad: Native script tag
|
||||
<script src="https://example.com/script.js"></script>;
|
||||
|
||||
// Good: Next.js Script component
|
||||
import Script from 'next/script';
|
||||
|
||||
<Script src="https://example.com/script.js" />;
|
||||
```
|
||||
|
||||
## Inline Scripts Need ID
|
||||
|
||||
Inline scripts require an `id` attribute for Next.js to track them.
|
||||
|
||||
```tsx
|
||||
// Bad: Missing id
|
||||
<Script dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
|
||||
|
||||
// Good: Has id
|
||||
<Script id="my-script" dangerouslySetInnerHTML={{ __html: 'console.log("hi")' }} />
|
||||
|
||||
// Good: Inline with id
|
||||
<Script id="show-banner">
|
||||
{`document.getElementById('banner').classList.remove('hidden')`}
|
||||
</Script>
|
||||
```
|
||||
|
||||
## Don't Put Script in Head
|
||||
|
||||
`next/script` should not be placed inside `next/head`. It handles its own positioning.
|
||||
|
||||
```tsx
|
||||
// Bad: Script inside Head
|
||||
import Head from 'next/head'
|
||||
import Script from 'next/script'
|
||||
|
||||
<Head>
|
||||
<Script src="/analytics.js" />
|
||||
</Head>
|
||||
|
||||
// Good: Script outside Head
|
||||
<Head>
|
||||
<title>Page</title>
|
||||
</Head>
|
||||
<Script src="/analytics.js" />
|
||||
```
|
||||
|
||||
## Loading Strategies
|
||||
|
||||
```tsx
|
||||
// afterInteractive (default) - Load after page is interactive
|
||||
<Script src="/analytics.js" strategy="afterInteractive" />
|
||||
|
||||
// lazyOnload - Load during idle time
|
||||
<Script src="/widget.js" strategy="lazyOnload" />
|
||||
|
||||
// beforeInteractive - Load before page is interactive (use sparingly)
|
||||
// Only works in app/layout.tsx or pages/_document.js
|
||||
<Script src="/critical.js" strategy="beforeInteractive" />
|
||||
|
||||
// worker - Load in web worker (experimental)
|
||||
<Script src="/heavy.js" strategy="worker" />
|
||||
```
|
||||
|
||||
## Google Analytics
|
||||
|
||||
Use `@next/third-parties` instead of inline GA scripts.
|
||||
|
||||
```tsx
|
||||
// Bad: Inline GA script
|
||||
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXX" />
|
||||
<Script id="ga-init">
|
||||
{`window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-XXXXX');`}
|
||||
</Script>
|
||||
|
||||
// Good: Next.js component
|
||||
import { GoogleAnalytics } from '@next/third-parties/google'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<body>{children}</body>
|
||||
<GoogleAnalytics gaId="G-XXXXX" />
|
||||
</html>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Google Tag Manager
|
||||
|
||||
```tsx
|
||||
import { GoogleTagManager } from '@next/third-parties/google';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<html>
|
||||
<GoogleTagManager gtmId="GTM-XXXXX" />
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Other Third-Party Scripts
|
||||
|
||||
```tsx
|
||||
// YouTube embed
|
||||
import { YouTubeEmbed } from '@next/third-parties/google';
|
||||
|
||||
<YouTubeEmbed videoid="dQw4w9WgXcQ" />;
|
||||
|
||||
// Google Maps
|
||||
import { GoogleMapsEmbed } from '@next/third-parties/google';
|
||||
|
||||
<GoogleMapsEmbed apiKey="YOUR_API_KEY" mode="place" q="Brooklyn+Bridge,New+York,NY" />;
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Pattern | Issue | Fix |
|
||||
| --------------------------------------------- | -------------------------- | ------------------------- |
|
||||
| `<script src="...">` | No optimization | Use `next/script` |
|
||||
| `<Script>` without id | Can't track inline scripts | Add `id` attribute |
|
||||
| `<Script>` inside `<Head>` | Wrong placement | Move outside Head |
|
||||
| Inline GA/GTM scripts | No optimization | Use `@next/third-parties` |
|
||||
| `strategy="beforeInteractive"` outside layout | Won't work | Only use in root layout |
|
||||
@@ -0,0 +1,375 @@
|
||||
# Self-Hosting Next.js
|
||||
|
||||
Deploy Next.js outside of Vercel with confidence.
|
||||
|
||||
## Quick Start: Standalone Output
|
||||
|
||||
For Docker or any containerized deployment, use standalone output:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
output: 'standalone',
|
||||
};
|
||||
```
|
||||
|
||||
This creates a minimal `standalone` folder with only production dependencies:
|
||||
|
||||
```
|
||||
.next/
|
||||
├── standalone/
|
||||
│ ├── server.js # Entry point
|
||||
│ ├── node_modules/ # Only production deps
|
||||
│ └── .next/ # Build output
|
||||
└── static/ # Must be copied separately
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Build
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3000/api/health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
## PM2 Deployment
|
||||
|
||||
For traditional server deployments:
|
||||
|
||||
```js
|
||||
// ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'nextjs',
|
||||
script: '.next/standalone/server.js',
|
||||
instances: 'max',
|
||||
exec_mode: 'cluster',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
pm2 start ecosystem.config.js
|
||||
```
|
||||
|
||||
## ISR and Cache Handlers
|
||||
|
||||
### The Problem
|
||||
|
||||
ISR (Incremental Static Regeneration) uses filesystem caching by default. This **breaks with multiple instances**:
|
||||
|
||||
- Instance A regenerates page → saves to its local disk
|
||||
- Instance B serves stale page → doesn't see Instance A's cache
|
||||
- Load balancer sends users to random instances → inconsistent content
|
||||
|
||||
### Solution: Custom Cache Handler
|
||||
|
||||
Next.js 14+ supports custom cache handlers for shared storage:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
cacheHandler: require.resolve('./cache-handler.js'),
|
||||
cacheMaxMemorySize: 0, // Disable in-memory cache
|
||||
};
|
||||
```
|
||||
|
||||
#### Redis Cache Handler Example
|
||||
|
||||
```js
|
||||
// cache-handler.js
|
||||
const Redis = require('ioredis');
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
const CACHE_PREFIX = 'nextjs:';
|
||||
|
||||
module.exports = class CacheHandler {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
const data = await redis.get(CACHE_PREFIX + key);
|
||||
if (!data) return null;
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
value: parsed.value,
|
||||
lastModified: parsed.lastModified,
|
||||
};
|
||||
}
|
||||
|
||||
async set(key, data, ctx) {
|
||||
const cacheData = {
|
||||
value: data,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
|
||||
// Set TTL based on revalidate option
|
||||
if (ctx?.revalidate) {
|
||||
await redis.setex(CACHE_PREFIX + key, ctx.revalidate, JSON.stringify(cacheData));
|
||||
} else {
|
||||
await redis.set(CACHE_PREFIX + key, JSON.stringify(cacheData));
|
||||
}
|
||||
}
|
||||
|
||||
async revalidateTag(tags) {
|
||||
// Implement tag-based invalidation
|
||||
// This requires tracking which keys have which tags
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### S3 Cache Handler Example
|
||||
|
||||
```js
|
||||
// cache-handler.js
|
||||
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
|
||||
const s3 = new S3Client({ region: process.env.AWS_REGION });
|
||||
const BUCKET = process.env.CACHE_BUCKET;
|
||||
|
||||
module.exports = class CacheHandler {
|
||||
async get(key) {
|
||||
try {
|
||||
const response = await s3.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: `cache/${key}`,
|
||||
})
|
||||
);
|
||||
const body = await response.Body.transformToString();
|
||||
return JSON.parse(body);
|
||||
} catch (err) {
|
||||
if (err.name === 'NoSuchKey') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key, data, ctx) {
|
||||
await s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: `cache/${key}`,
|
||||
Body: JSON.stringify({
|
||||
value: data,
|
||||
lastModified: Date.now(),
|
||||
}),
|
||||
ContentType: 'application/json',
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## What Works vs What Needs Setup
|
||||
|
||||
| Feature | Single Instance | Multi-Instance | Notes |
|
||||
| -------------------- | --------------- | ------------------- | --------------------------- |
|
||||
| SSR | Yes | Yes | No special setup |
|
||||
| SSG | Yes | Yes | Built at deploy time |
|
||||
| ISR | Yes | Needs cache handler | Filesystem cache breaks |
|
||||
| Image Optimization | Yes | Yes | CPU-intensive, consider CDN |
|
||||
| Middleware | Yes | Yes | Runs on Node.js |
|
||||
| Edge Runtime | Limited | Limited | Some features Node-only |
|
||||
| `revalidatePath/Tag` | Yes | Needs cache handler | Must share cache |
|
||||
| `next/font` | Yes | Yes | Fonts bundled at build |
|
||||
| Draft Mode | Yes | Yes | Cookie-based |
|
||||
|
||||
## Image Optimization
|
||||
|
||||
Next.js Image Optimization works out of the box but is CPU-intensive.
|
||||
|
||||
### Option 1: Built-in (Simple)
|
||||
|
||||
Works automatically, but consider:
|
||||
|
||||
- Set `deviceSizes` and `imageSizes` in config to limit variants
|
||||
- Use `minimumCacheTTL` to reduce regeneration
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
images: {
|
||||
minimumCacheTTL: 60 * 60 * 24, // 24 hours
|
||||
deviceSizes: [640, 750, 1080, 1920], // Limit sizes
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Option 2: External Loader (Recommended for Scale)
|
||||
|
||||
Offload to Cloudinary, Imgix, or similar:
|
||||
|
||||
```js
|
||||
// next.config.js
|
||||
module.exports = {
|
||||
images: {
|
||||
loader: 'custom',
|
||||
loaderFile: './lib/image-loader.js',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
```js
|
||||
// lib/image-loader.js
|
||||
export default function cloudinaryLoader({ src, width, quality }) {
|
||||
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`];
|
||||
return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`;
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Build-time vs Runtime
|
||||
|
||||
```js
|
||||
// Available at build time only (baked into bundle)
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
|
||||
// Available at runtime (server-side only)
|
||||
DATABASE_URL=postgresql://...
|
||||
API_SECRET=...
|
||||
```
|
||||
|
||||
### Runtime Configuration
|
||||
|
||||
For truly dynamic config, don't use `NEXT_PUBLIC_*`. Instead:
|
||||
|
||||
```tsx
|
||||
// app/api/config/route.ts
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
apiUrl: process.env.API_URL,
|
||||
features: process.env.FEATURES?.split(','),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## OpenNext: Serverless Without Vercel
|
||||
|
||||
[OpenNext](https://open-next.js.org/) adapts Next.js for AWS Lambda, Cloudflare Workers, etc.
|
||||
|
||||
```bash
|
||||
npx create-sst@latest
|
||||
# or
|
||||
npx @opennextjs/aws build
|
||||
```
|
||||
|
||||
Supports:
|
||||
|
||||
- AWS Lambda + CloudFront
|
||||
- Cloudflare Workers
|
||||
- Netlify Functions
|
||||
- Deno Deploy
|
||||
|
||||
## Health Check Endpoint
|
||||
|
||||
Always include a health check for load balancers:
|
||||
|
||||
```tsx
|
||||
// app/api/health/route.ts
|
||||
export async function GET() {
|
||||
try {
|
||||
// Optional: check database connection
|
||||
// await db.$queryRaw`SELECT 1`;
|
||||
|
||||
return Response.json({ status: 'healthy' }, { status: 200 });
|
||||
} catch (error) {
|
||||
return Response.json({ status: 'unhealthy' }, { status: 503 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
1. **Build locally first**: `npm run build` - catch errors before deploy
|
||||
2. **Test standalone output**: `node .next/standalone/server.js`
|
||||
3. **Set `output: 'standalone'`** for Docker
|
||||
4. **Configure cache handler** for multi-instance ISR
|
||||
5. **Set `HOSTNAME="0.0.0.0"`** for containers
|
||||
6. **Copy `public/` and `.next/static/`** - not included in standalone
|
||||
7. **Add health check endpoint**
|
||||
8. **Test ISR revalidation** after deployment
|
||||
9. **Monitor memory usage** - Node.js defaults may need tuning
|
||||
|
||||
## Testing Cache Handler
|
||||
|
||||
**Critical**: Test your cache handler on every Next.js upgrade:
|
||||
|
||||
```bash
|
||||
# Start multiple instances
|
||||
PORT=3001 node .next/standalone/server.js &
|
||||
PORT=3002 node .next/standalone/server.js &
|
||||
|
||||
# Trigger ISR revalidation
|
||||
curl http://localhost:3001/api/revalidate?path=/posts
|
||||
|
||||
# Verify both instances see the update
|
||||
curl http://localhost:3001/posts
|
||||
curl http://localhost:3002/posts
|
||||
# Should return identical content
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
# Suspense Boundaries
|
||||
|
||||
Client hooks that cause CSR bailout without Suspense boundaries.
|
||||
|
||||
## useSearchParams
|
||||
|
||||
Always requires Suspense boundary in static routes. Without it, the entire page becomes client-side rendered.
|
||||
|
||||
```tsx
|
||||
// Bad: Entire page becomes CSR
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
export default function SearchBar() {
|
||||
const searchParams = useSearchParams();
|
||||
return <div>Query: {searchParams.get('q')}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Good: Wrap in Suspense
|
||||
import { Suspense } from 'react';
|
||||
import SearchBar from './search-bar';
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<SearchBar />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## usePathname
|
||||
|
||||
Requires Suspense boundary when route has dynamic parameters.
|
||||
|
||||
```tsx
|
||||
// In dynamic route [slug]
|
||||
// Bad: No Suspense
|
||||
'use client';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function Breadcrumb() {
|
||||
const pathname = usePathname();
|
||||
return <nav>{pathname}</nav>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Good: Wrap in Suspense
|
||||
<Suspense fallback={<BreadcrumbSkeleton />}>
|
||||
<Breadcrumb />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
If you use `generateStaticParams`, Suspense is optional.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Hook | Suspense Required |
|
||||
| ------------------- | -------------------- |
|
||||
| `useSearchParams()` | Yes |
|
||||
| `usePathname()` | Yes (dynamic routes) |
|
||||
| `useParams()` | No |
|
||||
| `useRouter()` | No |
|
||||
@@ -0,0 +1,100 @@
|
||||
# Two-Phase File Upload (Frontend)
|
||||
|
||||
Pair with [backend two-phase upload rule](../nestjs-best-practices/rules/security-file-two-phase-upload.md).
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
User drops file
|
||||
→ POST /files/upload (temp) → { tempId, expiresAt }
|
||||
→ store tempId in form state
|
||||
→ user submits form
|
||||
→ POST /correspondences (with tempFileIds) → backend commits in transaction
|
||||
```
|
||||
|
||||
## Hook Pattern
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
export function useTwoPhaseUpload() {
|
||||
const uploadTemp = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const { data } = await apiClient.post<{ tempId: string; expiresAt: string }>(
|
||||
'/files/upload',
|
||||
fd,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return uploadTemp;
|
||||
}
|
||||
```
|
||||
|
||||
## Form Integration (RHF)
|
||||
|
||||
```tsx
|
||||
export function CorrespondenceForm() {
|
||||
const form = useForm<FormData>({ resolver: zodResolver(schema) });
|
||||
const uploadTemp = useTwoPhaseUpload();
|
||||
const [tempFileIds, setTempFileIds] = useState<string[]>([]);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'image/vnd.dwg': ['.dwg'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/zip': ['.zip'],
|
||||
},
|
||||
maxSize: 50 * 1024 * 1024, // 50 MB — must match backend
|
||||
onDrop: async (files) => {
|
||||
const results = await Promise.all(files.map((f) => uploadTemp.mutateAsync(f)));
|
||||
setTempFileIds((prev) => [...prev, ...results.map((r) => r.tempId)]);
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: FormData) => {
|
||||
await correspondenceService.create({
|
||||
...values,
|
||||
tempFileIds, // committed server-side in the same DB transaction
|
||||
});
|
||||
setTempFileIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div {...getRootProps()} className="dropzone">
|
||||
<input {...getInputProps()} />
|
||||
<p>{t('upload.dragDrop')}</p>
|
||||
</div>
|
||||
{/* other fields */}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Whitelist MIME types** — must mirror backend ADR-016 whitelist (`.pdf`, `.dwg`, `.docx`, `.xlsx`, `.zip`).
|
||||
- **50 MB cap** — enforce client-side too (better UX) plus server-side (authoritative).
|
||||
- **Show temp-file pills** with remove button — users see what will be attached.
|
||||
- **Clear `tempFileIds` on success/cancel** — prevent stale IDs on subsequent submits.
|
||||
- **No retry of expired temps** — if `expiresAt` passed, prompt re-upload.
|
||||
|
||||
## ❌ Forbidden
|
||||
|
||||
- ❌ Uploading directly to permanent storage endpoint (no commit phase)
|
||||
- ❌ Hardcoded MIME list in component (keep in shared constant file mirrored from backend)
|
||||
- ❌ Ignoring `maxSize` — backend will reject but UX suffers
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-016 Security](../../../specs/06-Decision-Records/ADR-016-security-authentication.md)
|
||||
- Backend rule: [`security-file-two-phase-upload.md`](../nestjs-best-practices/rules/security-file-two-phase-upload.md)
|
||||
@@ -0,0 +1,257 @@
|
||||
# UUID Handling (ADR-019) — March 2026 Pattern
|
||||
|
||||
**Project-specific: Hybrid Identifier Strategy for NAP-DMS**
|
||||
|
||||
This project uses ADR-019: INT Primary Key (internal) + UUIDv7 (public API). Frontend code must handle this correctly.
|
||||
|
||||
> **Updated pattern:** Backend exposes `publicId` directly — ไม่มี `@Expose({ name: 'id' })` rename แล้ว. Frontend ใช้ `publicId` ตรงๆ — ห้าม fallback ไป `id`.
|
||||
|
||||
## The Pattern
|
||||
|
||||
| Source | Field Name | Type | Notes |
|
||||
| ------------------------ | ------------------- | ----------------- | ----------------------------------------------------------- |
|
||||
| **API Response** | `publicId` | `string` (UUIDv7) | Exposed directly (no rename) |
|
||||
| **TypeScript Interface** | `publicId?: string` | UUID string | ใช้ตัวนี้เท่านั้น |
|
||||
| **Form DTO** | `xxxUuid` | `string` | DTO field names: `projectUuid`, `contractUuid` (input only) |
|
||||
| **URL param** | `[publicId]` | `string` (UUID) | e.g. `/correspondences/[publicId]/page.tsx` |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
### 1. NEVER Use `parseInt()` on UUID
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG - parseInt on UUID gives garbage
|
||||
const id = parseInt(projectId); // "0195a1b2-..." → 195 (wrong!)
|
||||
|
||||
// ❌ WRONG - Number() on UUID
|
||||
const id = Number(projectId); // NaN
|
||||
|
||||
// ❌ WRONG - Unary plus
|
||||
const id = +projectId; // NaN
|
||||
|
||||
// ✅ CORRECT - Send UUID string directly to API
|
||||
apiClient.get(`/projects/${projectId}`); // projectId is already UUID string
|
||||
```
|
||||
|
||||
### 2. Use `publicId` Only — NO `id ?? ''` Fallback
|
||||
|
||||
```tsx
|
||||
// ✅ CORRECT — types/project.ts
|
||||
interface Project {
|
||||
publicId?: string; // UUID from API — ใช้ตัวนี้เท่านั้น
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
// ✅ CORRECT — Component usage
|
||||
const projectOptions = projects.map((p) => ({
|
||||
label: `${p.projectName} (${p.projectCode})`,
|
||||
value: p.publicId ?? '', // ADR-019 — ไม่ต้อง String() และไม่ไป id
|
||||
key: p.publicId ?? p.projectCode, // fallback ไป business field ได้
|
||||
}));
|
||||
|
||||
// ❌ WRONG — pattern เก่า
|
||||
const oldOptions = projects.map((p) => ({
|
||||
value: String(p.publicId ?? p.id ?? ''), // ❌ `id ?? ''` fallback
|
||||
}));
|
||||
```
|
||||
|
||||
### 3. Form Field Names (camelCase)
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG - snake_case doesn't match TypeScript interface
|
||||
fields={[{ name: 'project_id', label: 'Project' }]}
|
||||
|
||||
// ✅ CORRECT - camelCase matches interface
|
||||
fields={[{ name: 'projectUuid', label: 'Project' }]}
|
||||
|
||||
// Form submission
|
||||
const onSubmit = (data: { projectUuid: string }) => {
|
||||
// projectUuid is UUID string - send as-is
|
||||
await apiClient.post('/contracts', data);
|
||||
};
|
||||
```
|
||||
|
||||
## Select Component Pattern
|
||||
|
||||
```tsx
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface ContractSelectProps {
|
||||
contracts: Contract[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ContractSelect({ contracts, value, onChange }: ContractSelectProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="เลือกสัญญา" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contracts
|
||||
.filter((c) => !!c.publicId) // กรอง contract ที่มี publicId เท่านั้น
|
||||
.map((c) => (
|
||||
<SelectItem key={c.publicId} value={c.publicId!}>
|
||||
{c.contractName} ({c.contractCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Table Pattern
|
||||
|
||||
```tsx
|
||||
// Show relation columns with UUID entities
|
||||
const columns: ColumnDef<Discipline>[] = [
|
||||
{
|
||||
accessorKey: 'disciplineCode',
|
||||
header: 'Code',
|
||||
},
|
||||
{
|
||||
accessorKey: 'contract',
|
||||
header: 'Contract',
|
||||
cell: ({ row }) => {
|
||||
const contract = row.original.contract;
|
||||
return contract ? (
|
||||
<span>
|
||||
{contract.contractName} ({contract.contractCode})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## API Service Pattern
|
||||
|
||||
```tsx
|
||||
// lib/services/contract.service.ts
|
||||
export const contractService = {
|
||||
async getById(uuid: string): Promise<Contract> {
|
||||
// Send UUID string directly - backend resolves to INT
|
||||
const { data } = await apiClient.get(`/contracts/${uuid}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(dto: CreateContractDto): Promise<Contract> {
|
||||
// DTO contains projectUuid (UUID string)
|
||||
const { data } = await apiClient.post('/contracts', dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(uuid: string, dto: Partial<CreateContractDto>): Promise<Contract> {
|
||||
const { data } = await apiClient.put(`/contracts/${uuid}`, dto);
|
||||
return data;
|
||||
},
|
||||
|
||||
async delete(uuid: string): Promise<void> {
|
||||
await apiClient.delete(`/contracts/${uuid}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## TypeScript Interfaces
|
||||
|
||||
```tsx
|
||||
// ✅ CORRECT — types/entities.ts
|
||||
export interface BaseEntity {
|
||||
publicId?: string; // UUID — ใช้ตัวนี้เท่านั้น (ไม่มี INT id ใน interface)
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Project extends BaseEntity {
|
||||
projectCode: string;
|
||||
projectName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Contract extends BaseEntity {
|
||||
contractCode: string;
|
||||
contractName: string;
|
||||
project?: Project; // Relation (nested entity)
|
||||
}
|
||||
|
||||
// DTO (input only — รับ UUID จาก form)
|
||||
export interface CreateContractDto {
|
||||
projectUuid: string; // UUID string from select
|
||||
contractCode: string;
|
||||
contractName: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Form with React Hook Form + Zod
|
||||
|
||||
```tsx
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
|
||||
const formSchema = z.object({
|
||||
projectUuid: z.string().uuid('กรุณาเลือกโปรเจกต์'),
|
||||
contractCode: z.string().min(1, 'กรุณาระบุรหัสสัญญา'),
|
||||
contractName: z.string().min(1, 'กรุณาระบุชื่อสัญญา'),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
export function ContractForm() {
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
projectUuid: '',
|
||||
contractCode: '',
|
||||
contractName: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
// Send UUID strings directly
|
||||
await contractService.create(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>{/* Form fields */}</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## URL Parameters
|
||||
|
||||
```tsx
|
||||
// app/contracts/[id]/page.tsx
|
||||
export default async function ContractPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
// id is UUID string from URL
|
||||
const contract = await contractService.getById(id);
|
||||
|
||||
return <ContractDetail contract={contract} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
| Pitfall | ❌ Wrong | ✅ Right |
|
||||
| ---------------------------- | ------------------------------------------------ | --------------------------------- |
|
||||
| Using INT `id` | `key={entity.id}` | `key={entity.publicId}` |
|
||||
| parseInt on UUID | `parseInt(projectId)` | `projectId` (string) |
|
||||
| Field name mismatch | `name="project_id"` | `name="projectUuid"` |
|
||||
| `id ?? ''` fallback | `value={publicId ?? id ?? ''}` | `value={publicId ?? ''}` |
|
||||
| `uuid` + `publicId` together | `interface { uuid?: string; publicId?: string }` | `interface { publicId?: string }` |
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-019 Hybrid Identifier Strategy](../../../../specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md)
|
||||
- [Frontend Guidelines](../../../../specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md)
|
||||
- [UUID Implementation Plan](../../../../specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md)
|
||||
|
||||
> **Warning**: Using `parseInt()` on UUID values causes data corruption. Always use UUID strings directly in API calls.
|
||||
@@ -0,0 +1,517 @@
|
||||
---
|
||||
name: security-review
|
||||
description: Comprehensive security review for LCBP3-DMS with OWASP Top 10 checklist, ADR compliance, and automated security testing patterns.
|
||||
version: 1.9.0
|
||||
scope: security
|
||||
depends-on: []
|
||||
handoffs-to: [speckit-reviewer, speckit-security-audit]
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Security Review Skill
|
||||
|
||||
Comprehensive security review for LCBP3-DMS ensuring all code follows security best practices and identifies potential vulnerabilities.
|
||||
|
||||
## LCBP3 Context
|
||||
|
||||
See [`_LCBP3-CONTEXT.md`](../_LCBP3-CONTEXT.md) for project-specific security requirements:
|
||||
- **ADR-016**: Security & Authentication (JWT, CASL, RBAC, file upload)
|
||||
- **ADR-018**: AI Boundary (Ollama on Admin Desktop only, no direct DB/storage access)
|
||||
- **ADR-019**: UUID Strategy (no parseInt/Number/+ on UUID)
|
||||
- **ADR-023**: Unified AI Architecture (AI via DMS API only)
|
||||
- **ADR-007**: Error Handling (layered error classification)
|
||||
|
||||
## When to Activate
|
||||
|
||||
Invoke this skill:
|
||||
- Implementing authentication or authorization
|
||||
- Handling user input or file uploads
|
||||
- Creating new API endpoints
|
||||
- Working with secrets or credentials
|
||||
- Integrating AI features (Ollama/Qdrant)
|
||||
- Storing or transmitting sensitive data
|
||||
- Integrating third-party APIs
|
||||
|
||||
## Security Checklist
|
||||
|
||||
### 1. Secrets Management
|
||||
|
||||
#### FAIL: NEVER Do This
|
||||
```typescript
|
||||
const apiKey = "sk-proj-xxxxx" // Hardcoded secret
|
||||
const dbPassword = "password123" // In source code
|
||||
```
|
||||
|
||||
#### PASS: ALWAYS Do This
|
||||
```typescript
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
const dbUrl = process.env.DATABASE_URL
|
||||
|
||||
// Verify secrets exist
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured')
|
||||
}
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] No hardcoded API keys, tokens, or passwords
|
||||
- [ ] All secrets in environment variables
|
||||
- [ ] `.env.local` in .gitignore
|
||||
- [ ] No secrets in git history
|
||||
- [ ] Production secrets in QNAP docker-compose environment section (not .env files)
|
||||
|
||||
### 2. Input Validation
|
||||
|
||||
#### Always Validate User Input
|
||||
```typescript
|
||||
import { z } from 'zod'
|
||||
|
||||
// Define validation schema
|
||||
const CreateCorrespondenceSchema = z.object({
|
||||
subject: z.string().min(1).max(500),
|
||||
recipientId: z.string().uuid(),
|
||||
typeCode: z.string().min(1).max(50)
|
||||
})
|
||||
|
||||
// Validate before processing
|
||||
export async function createCorrespondence(input: unknown) {
|
||||
try {
|
||||
const validated = CreateCorrespondenceSchema.parse(input)
|
||||
return await correspondenceService.create(validated)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new BadRequestException(error.errors)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### File Upload Validation (ADR-016)
|
||||
```typescript
|
||||
function validateFileUpload(file: Express.Multer.File) {
|
||||
// Size check (50MB max per ADR-016)
|
||||
const maxSize = 50 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
throw new BadRequestException('File too large (max 50MB)')
|
||||
}
|
||||
|
||||
// Type check (whitelist: PDF, DWG, DOCX, XLSX, ZIP)
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/vnd.dwg',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/zip'
|
||||
]
|
||||
if (!allowedTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException('Invalid file type')
|
||||
}
|
||||
|
||||
// Extension check
|
||||
const allowedExtensions = ['.pdf', '.dwg', '.docx', '.xlsx', '.zip']
|
||||
const extension = path.extname(file.originalname).toLowerCase()
|
||||
if (!allowedExtensions.includes(extension)) {
|
||||
throw new BadRequestException('Invalid file extension')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] All user inputs validated with Zod (frontend) + class-validator (backend)
|
||||
- [ ] File uploads restricted (50MB max, whitelist types)
|
||||
- [ ] No direct use of user input in queries
|
||||
- [ ] Whitelist validation (not blacklist)
|
||||
- [ ] Error messages don't leak sensitive info
|
||||
|
||||
### 3. SQL Injection Prevention
|
||||
|
||||
#### FAIL: NEVER Concatenate SQL
|
||||
```typescript
|
||||
// DANGEROUS - SQL Injection vulnerability
|
||||
const query = `SELECT * FROM correspondences WHERE uuid = '${correspondenceUuid}'`
|
||||
await this.connection.query(query)
|
||||
```
|
||||
|
||||
#### PASS: ALWAYS Use TypeORM Parameterized Queries
|
||||
```typescript
|
||||
// Safe - TypeORM parameterized query
|
||||
const correspondence = await this.correspondenceRepository.findOne({
|
||||
where: { publicId: correspondenceUuid }
|
||||
})
|
||||
|
||||
// Or with QueryBuilder
|
||||
const result = await this.correspondenceRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.publicId = :uuid', { uuid: correspondenceUuid })
|
||||
.getOne()
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] All database queries use TypeORM parameterized queries
|
||||
- [ ] No string concatenation in SQL
|
||||
- [ ] TypeORM query builder used correctly
|
||||
- [ ] Schema verified before writing queries (ADR-009)
|
||||
|
||||
### 4. Authentication & Authorization (ADR-016)
|
||||
|
||||
#### JWT Token Handling
|
||||
```typescript
|
||||
// FAIL: WRONG: localStorage (vulnerable to XSS)
|
||||
localStorage.setItem('token', token)
|
||||
|
||||
// PASS: CORRECT: httpOnly cookies
|
||||
response.setHeader('Set-Cookie',
|
||||
`token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600`
|
||||
)
|
||||
```
|
||||
|
||||
#### Authorization Checks (CASL)
|
||||
```typescript
|
||||
// Controller with CASL guard
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, AbilitiesGuard)
|
||||
@CheckAbilities({ action: 'create', subject: 'Correspondence' })
|
||||
async create(@Body() dto: CreateCorrespondenceDto, @Request() req) {
|
||||
// Service logic
|
||||
}
|
||||
```
|
||||
|
||||
#### RBAC Matrix (ADR-016)
|
||||
- [ ] 4-Level RBAC matrix implemented (Admin, Manager, User, Viewer)
|
||||
- [ ] CASL AbilityFactory configured with correct permissions
|
||||
- [ ] JwtAuthGuard on all protected routes
|
||||
- [ ] RolesGuard for role-based access
|
||||
- [ ] AuditLogInterceptor on all mutation endpoints
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] Tokens stored in httpOnly cookies (not localStorage)
|
||||
- [ ] Authorization checks before sensitive operations
|
||||
- [ ] CASL abilities configured correctly
|
||||
- [ ] Role-based access control implemented
|
||||
- [ ] Session management secure
|
||||
|
||||
### 5. XSS Prevention
|
||||
|
||||
#### Sanitize HTML
|
||||
```typescript
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
// ALWAYS sanitize user-provided HTML
|
||||
function renderUserContent(html: string) {
|
||||
const clean = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p'],
|
||||
ALLOWED_ATTR: []
|
||||
})
|
||||
return <div dangerouslySetInnerHTML={{ __html: clean }} />
|
||||
}
|
||||
```
|
||||
|
||||
#### Content Security Policy (Next.js)
|
||||
```typescript
|
||||
// next.config.js
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: https:;
|
||||
font-src 'self';
|
||||
connect-src 'self' http://localhost:3001 https://192.168.10.8;
|
||||
`.replace(/\s{2,}/g, ' ').trim()
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] User-provided HTML sanitized
|
||||
- [ ] CSP headers configured
|
||||
- [ ] No unvalidated dynamic content rendering
|
||||
- [ ] React's built-in XSS protection used
|
||||
|
||||
### 6. CSRF Protection
|
||||
|
||||
#### CSRF Tokens
|
||||
```typescript
|
||||
import { csrf } from '@/lib/csrf'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = request.headers.get('X-CSRF-Token')
|
||||
|
||||
if (!csrf.verify(token)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid CSRF token' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Process request
|
||||
}
|
||||
```
|
||||
|
||||
#### SameSite Cookies
|
||||
```typescript
|
||||
response.setHeader('Set-Cookie',
|
||||
`session=${sessionId}; HttpOnly; Secure; SameSite=Strict`
|
||||
)
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] CSRF tokens on state-changing operations
|
||||
- [ ] SameSite=Strict on all cookies
|
||||
- [ ] Double-submit cookie pattern implemented
|
||||
|
||||
### 7. Rate Limiting (ADR-016)
|
||||
|
||||
#### API Rate Limiting
|
||||
```typescript
|
||||
import { ThrottlerGuard } from '@nestjs/throttler'
|
||||
|
||||
// Apply to auth endpoints
|
||||
@UseGuards(ThrottlerGuard)
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
async login(@Body() dto: LoginDto) {
|
||||
// Login logic
|
||||
}
|
||||
```
|
||||
|
||||
#### Expensive Operations
|
||||
```typescript
|
||||
// Aggressive rate limiting for AI endpoints
|
||||
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||
async extractMetadata(@Body() dto: ExtractMetadataDto) {
|
||||
// AI extraction logic
|
||||
}
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] Rate limiting on all auth endpoints (ADR-016)
|
||||
- [ ] Rate limiting on AI endpoints (ADR-018/023)
|
||||
- [ ] IP-based rate limiting
|
||||
- [ ] User-based rate limiting (authenticated)
|
||||
|
||||
### 8. Sensitive Data Exposure
|
||||
|
||||
#### Logging
|
||||
```typescript
|
||||
// FAIL: WRONG: Logging sensitive data
|
||||
this.logger.log('User login:', { email, password })
|
||||
this.logger.log('Payment:', { cardNumber, cvv })
|
||||
|
||||
// PASS: CORRECT: Redact sensitive data
|
||||
this.logger.log('User login:', { email, userId })
|
||||
this.logger.log('Payment:', { last4: card.last4, userId })
|
||||
```
|
||||
|
||||
#### Error Messages (ADR-007)
|
||||
```typescript
|
||||
// FAIL: WRONG: Exposing internal details
|
||||
catch (error) {
|
||||
return { error: error.message, stack: error.stack }
|
||||
}
|
||||
|
||||
// PASS: CORRECT: Generic error messages
|
||||
catch (error) {
|
||||
this.logger.error('Internal error:', error)
|
||||
throw new BadRequestException('An error occurred. Please try again.')
|
||||
}
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] No passwords, tokens, or secrets in logs
|
||||
- [ ] Error messages generic for users
|
||||
- [ ] Detailed errors only in server logs
|
||||
- [ ] No stack traces exposed to users
|
||||
|
||||
### 9. AI Boundary Enforcement (ADR-018/023)
|
||||
|
||||
#### FAIL: NEVER Do This
|
||||
```typescript
|
||||
// Direct AI access - FORBIDDEN
|
||||
import ollama from 'ollama'
|
||||
const response = await ollama.chat({ model: 'gemma4', messages })
|
||||
|
||||
// Direct Qdrant access - FORBIDDEN
|
||||
import { QdrantClient } from '@qdrant/js-client-rest'
|
||||
const client = new QdrantClient({ url: 'http://localhost:6333' })
|
||||
```
|
||||
|
||||
#### PASS: ALWAYS Do This
|
||||
```typescript
|
||||
// AI via DMS API only
|
||||
const response = await fetch('http://localhost:3001/api/ai/extract-metadata', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId })
|
||||
})
|
||||
|
||||
// Qdrant via DMS API only
|
||||
const response = await fetch('http://localhost:3001/api/ai/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, projectPublicId })
|
||||
})
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] AI processing on Admin Desktop only (Desk-5439)
|
||||
- [ ] No direct Ollama calls from backend/frontend
|
||||
- [ ] No direct Qdrant calls from backend/frontend
|
||||
- [ ] All AI interactions via DMS API endpoints
|
||||
- [ ] AI audit logging implemented (ADR-020)
|
||||
- [ ] Human-in-the-loop validation for AI outputs
|
||||
|
||||
### 10. UUID Handling (ADR-019)
|
||||
|
||||
#### FAIL: NEVER Do This
|
||||
```typescript
|
||||
// parseInt on UUID - FORBIDDEN
|
||||
const projectId = parseInt(projectUuid) // "0195..." → 19 (WRONG!)
|
||||
|
||||
// Number on UUID - FORBIDDEN
|
||||
const projectId = Number(projectUuid)
|
||||
|
||||
// + operator on UUID - FORBIDDEN
|
||||
const projectId = +projectUuid
|
||||
|
||||
// id ?? '' fallback - FORBIDDEN
|
||||
const value = c.publicId ?? c.id ?? ''
|
||||
```
|
||||
|
||||
#### PASS: ALWAYS Do This
|
||||
```typescript
|
||||
// Use UUID string directly
|
||||
const projectId = projectUuid // "019505a1-7c3e-7000-8000-abc123def456"
|
||||
|
||||
// Backend: findOneByUuid returns entity with publicId
|
||||
const project = await this.projectService.findOneByUuid(projectUuid)
|
||||
const projectId = project.id // Internal INT for DB operations
|
||||
|
||||
// Frontend: use publicId only
|
||||
interface ProjectOption {
|
||||
publicId?: string; // No uuid fallback
|
||||
projectName?: string;
|
||||
}
|
||||
const value = c.publicId // "019505a1-7c3e-7000-8000-abc123def456"
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] No `parseInt()` on UUID values
|
||||
- [ ] No `Number()` on UUID values
|
||||
- [ ] No `+` operator on UUID values
|
||||
- [ ] No `id ?? ''` fallback patterns
|
||||
- [ ] Use `publicId` (string UUID) in API responses
|
||||
- [ ] Internal INT `id` marked with `@Exclude()` in entities
|
||||
|
||||
### 11. Dependency Security
|
||||
|
||||
#### Regular Updates
|
||||
```bash
|
||||
# Check for vulnerabilities
|
||||
pnpm audit
|
||||
|
||||
# Fix automatically fixable issues
|
||||
pnpm audit fix
|
||||
|
||||
# Update dependencies
|
||||
pnpm update
|
||||
|
||||
# Check for outdated packages
|
||||
pnpm outdated
|
||||
```
|
||||
|
||||
#### Lock Files
|
||||
```bash
|
||||
# ALWAYS commit lock files
|
||||
git add pnpm-lock.yaml
|
||||
|
||||
# Use in CI/CD for reproducible builds
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] Dependencies up to date
|
||||
- [ ] No known vulnerabilities (pnpm audit clean)
|
||||
- [ ] Lock files committed
|
||||
- [ ] Regular security updates
|
||||
|
||||
## Security Testing
|
||||
|
||||
### Automated Security Tests
|
||||
|
||||
```typescript
|
||||
// Test authentication
|
||||
test('requires authentication', async () => {
|
||||
const response = await fetch('/api/correspondences')
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
// Test authorization
|
||||
test('requires admin role', async () => {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
headers: { Authorization: `Bearer ${userToken}` }
|
||||
})
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
// Test input validation
|
||||
test('rejects invalid input', async () => {
|
||||
const response = await fetch('/api/correspondences', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subject: '', recipientId: 'invalid' })
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
// Test rate limiting
|
||||
test('enforces rate limits', async () => {
|
||||
const requests = Array(11).fill(null).map(() =>
|
||||
fetch('/api/auth/login', { method: 'POST' })
|
||||
)
|
||||
|
||||
const responses = await Promise.all(requests)
|
||||
const tooManyRequests = responses.filter(r => r.status === 429)
|
||||
|
||||
expect(tooManyRequests.length).toBeGreaterThan(0)
|
||||
})
|
||||
```
|
||||
|
||||
## Pre-Deployment Security Checklist
|
||||
|
||||
Before ANY production deployment:
|
||||
|
||||
- [ ] **Secrets**: No hardcoded secrets, all in env vars
|
||||
- [ ] **Input Validation**: All user inputs validated (Zod + class-validator)
|
||||
- [ ] **SQL Injection**: All queries parameterized (TypeORM)
|
||||
- [ ] **XSS**: User content sanitized
|
||||
- [ ] **CSRF**: Protection enabled
|
||||
- [ ] **Authentication**: Proper token handling (httpOnly cookies)
|
||||
- [ ] **Authorization**: RBAC + CASL checks in place
|
||||
- [ ] **Rate Limiting**: Enabled on auth and AI endpoints
|
||||
- [ ] **HTTPS**: Enforced in production
|
||||
- [ ] **Security Headers**: CSP, X-Frame-Options configured
|
||||
- [ ] **Error Handling**: No sensitive data in errors (ADR-007)
|
||||
- [ ] **Logging**: No sensitive data logged
|
||||
- [ ] **Dependencies**: Up to date, no vulnerabilities
|
||||
- [ ] **UUID Handling**: No parseInt/Number/+ on UUID (ADR-019)
|
||||
- [ ] **AI Boundary**: AI via DMS API only (ADR-018/023)
|
||||
- [ ] **File Uploads**: Validated (50MB max, whitelist types)
|
||||
- [ ] **AI Audit**: All AI interactions logged (ADR-020)
|
||||
|
||||
## Resources
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [NestJS Security](https://docs.nestjs.com/security)
|
||||
- [Next.js Security](https://nextjs.org/docs/security)
|
||||
- [ADR-016 Security Authentication](../../specs/06-Decision-Records/ADR-016-security-authentication.md)
|
||||
- [ADR-018 AI Boundary](../../specs/06-Decision-Records/ADR-018-ai-boundary.md)
|
||||
- [ADR-019 UUID Strategy](../../specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md)
|
||||
- [ADR-023 AI Architecture](../../specs/06-Decision-Records/ADR-023-unified-ai-architecture.md)
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Security is not optional. One vulnerability can compromise the entire platform. When in doubt, err on the side of caution.
|
||||
@@ -0,0 +1,111 @@
|
||||
# 🧠 NAP-DMS Agent Skills (v1.9.0)
|
||||
|
||||
ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.9.0 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity
|
||||
|
||||
**Status**: Production Ready | **Last Updated**: 2026-05-17 | **Total Skills**: 23
|
||||
|
||||
> 📌 Shared context for all speckit-\* skills: see [`_LCBP3-CONTEXT.md`](./_LCBP3-CONTEXT.md).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architectural & Data Integrity
|
||||
|
||||
- **Identifier Strategy Mastery (ADR-019 — March 2026):**
|
||||
- บังคับใช้ **UUIDv7** เป็น Public ID; entity สืบทอดจาก `UuidBaseEntity` และเปิด `publicId` **ตรงๆ** (ห้ามใช้ `@Expose({ name: 'id' })` rename)
|
||||
- ตรวจสอบและป้องกันการใช้ `parseInt()`, `Number()`, หรือ `+` กับ UUID ทั้ง backend/frontend
|
||||
- ตรวจสอบว่า Entity มีการใช้ `@Exclude()` บน Primary Key `INT AUTO_INCREMENT` เพื่อไม่ให้หลุดออกไปยัง API
|
||||
- Frontend ใช้ `publicId` ตรงๆ — **ห้าม** `id ?? ''` fallback หรือมี `uuid?: string` คู่กับ `publicId` ใน interface
|
||||
- **Strict Validation Engine:**
|
||||
- บังคับใช้ **Zod** สำหรับการทำ Form Validation ฝั่ง Frontend
|
||||
- บังคับใช้ **class-validator** สำหรับ Backend DTOs
|
||||
- ตรวจสอบการส่ง **Idempotency-Key** ใน Header สำหรับทุก Mutation Request (POST/PUT/PATCH)
|
||||
|
||||
## ⚙️ Workflow & Concurrency Control
|
||||
|
||||
- **DMS Workflow Engine Proficiency:**
|
||||
- มีความเชี่ยวชาญใน **DSL-based state machines**; ตรวจสอบทุกการเปลี่ยนสถานะเอกสารเทียบกับกฎใน DSL Parser เสมอ
|
||||
- ป้องกันการอนุมัติซ้ำซ้อนโดยการตรวจสอบสถานะปัจจุบันจากฐานข้อมูลก่อนเริ่ม Logic การเปลี่ยน State ทุกครั้ง
|
||||
- **Collision-Free Numbering (ADR-002):**
|
||||
- ใช้ทักษะการทำ **Distributed Locking** ผ่าน **Redis Redlock** ร่วมกับ TypeORM `@VersionColumn` สำหรับการเจนเลขที่เอกสาร (Document Numbering)
|
||||
- ห้ามเจนเลขโดยใช้ Logic ฝั่ง Application เพียงอย่างเดียวเด็ดขาด
|
||||
- **Asynchronous Task Orchestration (ADR-008):**
|
||||
- แยกงานที่ใช้เวลานาน (เช่น การส่ง Notification, การทำ Correspondence Routing) ไปทำที่ **BullMQ** เท่านั้น
|
||||
|
||||
## 🛡️ Security & Integrity Audit
|
||||
|
||||
- **RBAC Matrix Enforcement (ADR-016):**
|
||||
- บังคับใช้ **JwtAuthGuard**, **RolesGuard** และ **CASL AbilityFactory** ในทุก Controller ใหม่
|
||||
- ตรวจสอบการมีอยู่ของ `AuditLogInterceptor` สำหรับทุก API ที่มีการเปลี่ยนแปลงข้อมูล
|
||||
- **Secure File Lifecycle:**
|
||||
- ใช้ Logic **Two-Phase Upload**: Upload → Temp → ClamAV Scan → Commit → Permanent
|
||||
- บังคับใช้ Whitelist File Extension และ Max Size 50MB ตามที่กำหนดใน ADR-016
|
||||
|
||||
## 🤖 AI Boundary & Privacy (ADR-018/020)
|
||||
|
||||
- **Data Isolation:**
|
||||
- รับรองว่าฟีเจอร์ AI จะรันผ่าน **Ollama (On-premises)** เท่านั้น และไม่ส่งข้อมูลออกนอกเน็ตเวิร์ก
|
||||
- AI จะเข้าถึงข้อมูลผ่าน **DMS API** เท่านั้น (ห้ามต่อ Database หรือ Storage โดยตรง)
|
||||
- **Human-in-the-loop Validation:**
|
||||
- ออกแบบให้ผลลัพธ์จาก AI (เช่น การดึง Metadata เอกสาร) ต้องผ่านการยืนยันจาก User ก่อนบันทึกลงระบบเสมอ
|
||||
|
||||
## 🏷️ Domain Terminology Consistency
|
||||
|
||||
- **Term Correction:** แก้ไขคำศัพท์ให้ถูกต้องตาม Glossary ทันที (เช่น เปลี่ยน Letter เป็น **Correspondence**, Approval Flow เป็น **Workflow Engine**)
|
||||
- **i18n Guidelines:** ห้ามเขียน Thai/English String ลงใน Component โดยตรง ต้องใช้ i18n Keys เท่านั้น
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Skill Dependency Matrix
|
||||
|
||||
| Skill | Dependencies | Handoffs To | Notes |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- | ----------------------------- |
|
||||
| **speckit-constitution** | None | speckit-specify | Project governance foundation |
|
||||
| **speckit-specify** | speckit-constitution | speckit-clarify | Feature specification |
|
||||
| **speckit-clarify** | speckit-specify | speckit-plan | Resolve ambiguities |
|
||||
| **speckit-plan** | speckit-clarify | speckit-tasks, speckit-checklist | Technical design |
|
||||
| **speckit-tasks** | speckit-plan | speckit-implement | Task breakdown |
|
||||
| **speckit-implement** | speckit-tasks | speckit-checker | Code implementation |
|
||||
| **speckit-checker** | speckit-implement | speckit-tester | Static analysis |
|
||||
| **speckit-tester** | speckit-checker | speckit-reviewer | Test execution |
|
||||
| **speckit-reviewer** | speckit-tester | speckit-validate | Code review |
|
||||
| **speckit-validate** | speckit-reviewer | None | Requirements validation |
|
||||
| **speckit-analyze** | speckit-tasks | None | Cross-artifact consistency |
|
||||
| **speckit-migrate** | None | speckit-plan | Legacy code import |
|
||||
| **speckit-quizme** | speckit-specify | speckit-plan | Logic validation |
|
||||
| **speckit-diff** | None | speckit-plan | Version comparison |
|
||||
| **speckit-status** | None | None | Progress tracking |
|
||||
| **speckit-taskstoissues** | speckit-tasks | None | Issue sync |
|
||||
| **speckit-checklist** | speckit-plan | None | Requirements validation |
|
||||
| **nestjs-best-practices** | None | speckit-implement | Backend patterns |
|
||||
| **next-best-practices** | None | speckit-implement | Frontend patterns |
|
||||
| **speckit-security-audit** | None | speckit-reviewer | Security validation |
|
||||
| **e2e-testing** | None | speckit-tester | Playwright E2E patterns |
|
||||
| **verification-loop** | None | speckit-checker, speckit-tester | Comprehensive verification |
|
||||
| **security-review** | None | speckit-reviewer, speckit-security-audit | OWASP Top 10 + ADR compliance |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Skill Health Monitoring
|
||||
|
||||
### Health Check Scripts (from repo root)
|
||||
|
||||
- **Bash**: `./.agents/scripts/bash/audit-skills.sh` - Comprehensive skill health audit
|
||||
- **PowerShell**: `./.agents/scripts/powershell/audit-skills.ps1` - Windows equivalent
|
||||
|
||||
### Validation Scripts
|
||||
|
||||
- **Version Check**: `./.agents/scripts/bash/validate-versions.sh` - Ensure version consistency
|
||||
- **Workflow Sync**: `./.agents/scripts/bash/sync-workflows.sh` - Verify workflow integration
|
||||
|
||||
### Health Metrics
|
||||
|
||||
- **Total Skills**: 23 implemented
|
||||
- **Version Alignment**: v1.9.0 across all skills
|
||||
- **Template Coverage**: 100% for skills requiring templates
|
||||
- **Documentation**: Complete front matter + shared `_LCBP3-CONTEXT.md` appendix
|
||||
|
||||
### Maintenance Schedule
|
||||
|
||||
- **Daily**: Run `audit-skills.sh` for health monitoring
|
||||
- **Weekly**: Run `validate-versions.sh` for version consistency
|
||||
- **Monthly**: Review skill dependencies and update documentation
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
name: speckit-analyze
|
||||
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
|
||||
version: 1.9.0
|
||||
depends-on:
|
||||
- speckit-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Consistency Analyst**. Your role is to identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. You act with strict adherence to the project constitution.
|
||||
|
||||
## Task
|
||||
|
||||
### Goal
|
||||
|
||||
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
|
||||
|
||||
## Operating Constraints
|
||||
|
||||
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||
|
||||
**Constitution Authority**: The project constitution (`AGENTS.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
|
||||
|
||||
### Steps
|
||||
|
||||
### 1. Initialize Analysis Context
|
||||
|
||||
Run `../scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||
|
||||
- SPEC = FEATURE_DIR/spec.md
|
||||
- PLAN = FEATURE_DIR/plan.md
|
||||
- TASKS = FEATURE_DIR/tasks.md
|
||||
|
||||
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
### 2. Load Artifacts (Progressive Disclosure)
|
||||
|
||||
Load only the minimal necessary context from each artifact:
|
||||
|
||||
**From spec.md:**
|
||||
|
||||
- Overview/Context
|
||||
- Functional Requirements
|
||||
- Non-Functional Requirements
|
||||
- User Stories
|
||||
- Edge Cases (if present)
|
||||
|
||||
**From plan.md:**
|
||||
|
||||
- Architecture/stack choices
|
||||
- Data Model references
|
||||
- Phases
|
||||
- Technical constraints
|
||||
|
||||
**From tasks.md:**
|
||||
|
||||
- Task IDs
|
||||
- Descriptions
|
||||
- Phase grouping
|
||||
- Parallel markers [P]
|
||||
- Referenced file paths
|
||||
|
||||
**From constitution:**
|
||||
|
||||
- Load `AGENTS.md` for principle validation
|
||||
|
||||
### 3. Build Semantic Models
|
||||
|
||||
Create internal representations (do not include raw artifacts in output):
|
||||
|
||||
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
|
||||
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||
|
||||
### 4. Detection Passes (Token-Efficient Analysis)
|
||||
|
||||
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||
|
||||
#### A. Duplication Detection
|
||||
|
||||
- Identify near-duplicate requirements
|
||||
- Mark lower-quality phrasing for consolidation
|
||||
|
||||
#### B. Ambiguity Detection
|
||||
|
||||
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||
|
||||
#### C. Underspecification
|
||||
|
||||
- Requirements with verbs but missing object or measurable outcome
|
||||
- User stories missing acceptance criteria alignment
|
||||
- Tasks referencing files or components not defined in spec/plan
|
||||
|
||||
#### D. Constitution Alignment
|
||||
|
||||
- Any requirement or plan element conflicting with a MUST principle
|
||||
- Missing mandated sections or quality gates from constitution
|
||||
|
||||
#### E. Coverage Gaps
|
||||
|
||||
- Requirements with zero associated tasks
|
||||
- Tasks with no mapped requirement/story
|
||||
- Non-functional requirements not reflected in tasks (e.g., performance, security)
|
||||
|
||||
#### F. Inconsistency
|
||||
|
||||
- Terminology drift (same concept named differently across files)
|
||||
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||
|
||||
### 5. Severity Assignment
|
||||
|
||||
Use this heuristic to prioritize findings:
|
||||
|
||||
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||
|
||||
### 6. Produce Compact Analysis Report
|
||||
|
||||
Output a Markdown report (no file writes) with the following structure:
|
||||
|
||||
## Specification Analysis Report
|
||||
|
||||
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||
| --- | ----------- | -------- | ---------------- | ---------------------------- | ------------------------------------ |
|
||||
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||
|
||||
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||
|
||||
**Coverage Summary Table:**
|
||||
|
||||
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||
| --------------- | --------- | -------- | ----- |
|
||||
|
||||
**Constitution Alignment Issues:** (if any)
|
||||
|
||||
**Unmapped Tasks:** (if any)
|
||||
|
||||
**Metrics:**
|
||||
|
||||
- Total Requirements
|
||||
- Total Tasks
|
||||
- Coverage % (requirements with >=1 task)
|
||||
- Ambiguity Count
|
||||
- Duplication Count
|
||||
- Critical Issues Count
|
||||
|
||||
### 7. Provide Next Actions
|
||||
|
||||
At end of report, output a concise Next Actions block:
|
||||
|
||||
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
|
||||
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||
|
||||
### 8. Offer Remediation
|
||||
|
||||
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||
|
||||
## Operating Principles
|
||||
|
||||
### Context Efficiency
|
||||
|
||||
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||
|
||||
### Analysis Guidelines
|
||||
|
||||
- **NEVER modify files** (this is read-only analysis)
|
||||
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||
|
||||
## Context
|
||||
|
||||
{{args}}
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: speckit-checker
|
||||
description: Run static analysis tools and aggregate results.
|
||||
version: 1.9.0
|
||||
depends-on: []
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Static Analyzer**. Your role is to run all applicable static analysis tools and provide a unified report of issues.
|
||||
|
||||
## Task
|
||||
|
||||
### Outline
|
||||
|
||||
Auto-detect available tools, run them, and aggregate results into a prioritized report.
|
||||
|
||||
### Execution Steps
|
||||
|
||||
1. **Detect Project Type and Tools**:
|
||||
|
||||
```bash
|
||||
# Check for config files
|
||||
ls -la | grep -E "(package.json|pyproject.toml|go.mod|Cargo.toml|pom.xml)"
|
||||
|
||||
# Check for linter configs
|
||||
ls -la | grep -E "(eslint|prettier|pylint|golangci|rustfmt)"
|
||||
```
|
||||
|
||||
| Config | Tools to Run |
|
||||
| ---------------- | ----------------------------- |
|
||||
| `package.json` | ESLint, TypeScript, npm audit |
|
||||
| `pyproject.toml` | Pylint/Ruff, mypy, bandit |
|
||||
| `go.mod` | golangci-lint, go vet |
|
||||
| `Cargo.toml` | clippy, cargo audit |
|
||||
| `pom.xml` | SpotBugs, PMD |
|
||||
|
||||
2. **Run Linting**:
|
||||
|
||||
| Stack | Command |
|
||||
| ------- | ---------------------------------------------- | --- | ------------------------------------- |
|
||||
| Node/TS | `npx eslint . --format json 2>/dev/null` |
|
||||
| Python | `ruff check . --output-format json 2>/dev/null | | pylint --output-format=json \*_/_.py` |
|
||||
| Go | `golangci-lint run --out-format json` |
|
||||
| Rust | `cargo clippy --message-format=json` |
|
||||
|
||||
3. **Run Type Checking**:
|
||||
|
||||
| Stack | Command |
|
||||
| ---------- | ------------------------------------------ |
|
||||
| TypeScript | `npx tsc --noEmit 2>&1` |
|
||||
| Python | `mypy . --no-error-summary 2>&1` |
|
||||
| Go | `go build ./... 2>&1` (types are built-in) |
|
||||
|
||||
4. **Run Security Scanning**:
|
||||
|
||||
| Stack | Command |
|
||||
| ------ | -------------------------------- | --- | -------------------- |
|
||||
| Node | `npm audit --json` |
|
||||
| Python | `bandit -r . -f json 2>/dev/null | | safety check --json` |
|
||||
| Go | `govulncheck ./... 2>&1` |
|
||||
| Rust | `cargo audit --json` |
|
||||
|
||||
5. **Aggregate and Prioritize**:
|
||||
|
||||
| Category | Priority |
|
||||
| ------------------------ | -------- |
|
||||
| Security (Critical/High) | 🔴 P1 |
|
||||
| Type Errors | 🟠 P2 |
|
||||
| Security (Medium/Low) | 🟡 P3 |
|
||||
| Lint Errors | 🟡 P3 |
|
||||
| Lint Warnings | 🟢 P4 |
|
||||
| Style Issues | ⚪ P5 |
|
||||
|
||||
6. **Generate Report**:
|
||||
|
||||
````markdown
|
||||
# Static Analysis Report
|
||||
|
||||
**Date**: [timestamp]
|
||||
**Project**: [name from package.json/pyproject.toml]
|
||||
**Status**: CLEAN | ISSUES FOUND
|
||||
|
||||
## Tools Run
|
||||
|
||||
| Tool | Status | Issues |
|
||||
| ---------- | ------ | ----------------- |
|
||||
| ESLint | ✅ | 12 |
|
||||
| TypeScript | ✅ | 3 |
|
||||
| npm audit | ⚠️ | 2 vulnerabilities |
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count |
|
||||
| -------------- | ----- |
|
||||
| 🔴 P1 Critical | X |
|
||||
| 🟠 P2 High | X |
|
||||
| 🟡 P3 Medium | X |
|
||||
| 🟢 P4 Low | X |
|
||||
|
||||
## Issues
|
||||
|
||||
### 🔴 P1: Security Vulnerabilities
|
||||
|
||||
| Package | Severity | Issue | Fix |
|
||||
| ------- | -------- | ------------------- | ------------------ |
|
||||
| lodash | HIGH | Prototype Pollution | Upgrade to 4.17.21 |
|
||||
|
||||
### 🟠 P2: Type Errors
|
||||
|
||||
| File | Line | Error |
|
||||
| ---------- | ---- | ------------------------------------------------ |
|
||||
| src/api.ts | 45 | Type 'string' is not assignable to type 'number' |
|
||||
|
||||
### 🟡 P3: Lint Issues
|
||||
|
||||
| File | Line | Rule | Message |
|
||||
| ------------ | ---- | -------------- | ------------------------------- |
|
||||
| src/utils.ts | 12 | no-unused-vars | 'foo' is defined but never used |
|
||||
|
||||
## Quick Fixes
|
||||
|
||||
```bash
|
||||
# Fix security issues
|
||||
npm audit fix
|
||||
|
||||
# Auto-fix lint issues
|
||||
npx eslint . --fix
|
||||
```
|
||||
````
|
||||
|
||||
## Recommendations
|
||||
1. **Immediate**: Fix P1 security issues
|
||||
2. **Before merge**: Fix P2 type errors
|
||||
3. **Tech debt**: Address P3/P4 lint issues
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
7. **Output**:
|
||||
- Display report
|
||||
- Exit with non-zero if P1 or P2 issues exist
|
||||
|
||||
## Operating Principles
|
||||
|
||||
- **Run Everything**: Don't skip tools, aggregate all results
|
||||
- **Be Fast**: Run tools in parallel when possible
|
||||
- **Be Actionable**: Every issue should have a clear fix path
|
||||
- **Don't Duplicate**: Dedupe issues found by multiple tools
|
||||
- **Respect Configs**: Honor project's existing linter configs
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,314 @@
|
||||
---
|
||||
name: speckit-checklist
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
version: 1.9.0
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
|
||||
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||
|
||||
**NOT for verification/testing**:
|
||||
|
||||
- ❌ NOT "Verify the button clicks correctly"
|
||||
- ❌ NOT "Test error handling works"
|
||||
- ❌ NOT "Confirm the API returns 200"
|
||||
- ❌ NOT checking if code/implementation matches the spec
|
||||
|
||||
**FOR requirements quality validation**:
|
||||
|
||||
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Quality Gatekeeper**. Your role is to validate the quality of requirements by generating "Unit Tests for English"—checklists that ensure specifications are complete, clear, consistent, and measurable. You don't test the code; you test the documentation that defines it.
|
||||
|
||||
## Task
|
||||
|
||||
### Execution Steps
|
||||
|
||||
1. **Setup**: Run `../scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
- Prefer precision over breadth
|
||||
|
||||
Generation algorithm:
|
||||
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||
5. Formulate questions chosen from these archetypes:
|
||||
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||
|
||||
Question formatting rules:
|
||||
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||
- Never ask the user to restate what they already said
|
||||
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||
|
||||
Defaults when interaction impossible:
|
||||
- Depth: Standard
|
||||
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||
- Focus: Top 2 relevance clusters
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
|
||||
**Context Loading Strategy**:
|
||||
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
- Format: `[domain].md`
|
||||
- If file exists, append to existing file
|
||||
- Number items sequentially starting from CHK001
|
||||
- Each `/speckit-checklist` run creates a NEW file (never overwrites existing checklists)
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
- **Completeness**: Are all necessary requirements present?
|
||||
- **Clarity**: Are requirements unambiguous and specific?
|
||||
- **Consistency**: Do requirements align with each other?
|
||||
- **Measurability**: Can requirements be objectively verified?
|
||||
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||
|
||||
**Category Structure** - Group items by requirement quality dimensions:
|
||||
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||
|
||||
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||
|
||||
❌ **WRONG** (Testing implementation):
|
||||
- "Verify landing page displays 3 episode cards"
|
||||
- "Test hover states work on desktop"
|
||||
- "Confirm logo click navigates home"
|
||||
|
||||
✅ **CORRECT** (Testing requirements quality):
|
||||
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||
|
||||
**ITEM STRUCTURE**:
|
||||
Each item should follow this pattern:
|
||||
- Question format asking about requirement quality
|
||||
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||
- Use `[Gap]` marker when checking for missing requirements
|
||||
|
||||
**EXAMPLES BY QUALITY DIMENSION**:
|
||||
|
||||
Completeness:
|
||||
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||
|
||||
Clarity:
|
||||
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||
|
||||
Consistency:
|
||||
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||
|
||||
Coverage:
|
||||
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||
|
||||
Measurability:
|
||||
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||
|
||||
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||
|
||||
**Traceability Requirements**:
|
||||
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||
|
||||
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||
- Ask questions about the requirements themselves:
|
||||
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||
|
||||
**Content Consolidation**:
|
||||
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||
- Merge near-duplicates checking the same requirement aspect
|
||||
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||
|
||||
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||
- ❌ References to code execution, user actions, system behavior
|
||||
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||
- ❌ Test cases, test plans, QA procedures
|
||||
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||
|
||||
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
b. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
6. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
- Any explicit user-specified must-have items incorporated
|
||||
|
||||
**Important**: Each `/speckit-checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
|
||||
|
||||
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||
- Simple, memorable filenames that indicate checklist purpose
|
||||
- Easy identification and navigation in the `checklists/` folder
|
||||
|
||||
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||
|
||||
## Example Checklist Types & Sample Items
|
||||
|
||||
**UX Requirements Quality:** `ux.md`
|
||||
|
||||
Sample items (testing the requirements, NOT the implementation):
|
||||
|
||||
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||
|
||||
**API Requirements Quality:** `api.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||
- "Is versioning strategy documented in requirements? [Gap]"
|
||||
|
||||
**Performance Requirements Quality:** `performance.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||
- "Can performance requirements be objectively measured? [Measurability]"
|
||||
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||
|
||||
**Security Requirements Quality:** `security.md`
|
||||
|
||||
Sample items:
|
||||
|
||||
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||
|
||||
## Anti-Examples: What NOT To Do
|
||||
|
||||
**❌ WRONG - These test implementation, not requirements:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||
```
|
||||
|
||||
**✅ CORRECT - These test requirements quality:**
|
||||
|
||||
```markdown
|
||||
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||
```
|
||||
|
||||
**Key Differences:**
|
||||
|
||||
- Wrong: Tests if the system works correctly
|
||||
- Correct: Tests if the requirements are written correctly
|
||||
- Wrong: Verification of behavior
|
||||
- Correct: Validation of requirement quality
|
||||
- Wrong: "Does it do X?"
|
||||
- Correct: "Is X clearly specified?"
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,40 @@
|
||||
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: [Brief description of what this checklist covers]
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit-checklist command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## [Category 1]
|
||||
|
||||
- [ ] CHK001 First checklist item with clear action
|
||||
- [ ] CHK002 Second checklist item
|
||||
- [ ] CHK003 Third checklist item
|
||||
|
||||
## [Category 2]
|
||||
|
||||
- [ ] CHK004 Another category item
|
||||
- [ ] CHK005 Item with specific criteria
|
||||
- [ ] CHK006 Final item in this category
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
name: speckit-clarify
|
||||
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
|
||||
version: 1.9.0
|
||||
depends-on:
|
||||
- speckit-specify
|
||||
handoffs:
|
||||
- label: Build Technical Plan
|
||||
agent: speckit-plan
|
||||
prompt: Create a plan for the spec. I am building with...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Ambiguity Buster**. Your role is to interrogate specifications for logical gaps, missing constraints, or vague requirements. You resolve these via structured questioning to minimize rework risk.
|
||||
|
||||
## Task
|
||||
|
||||
### Outline
|
||||
|
||||
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||
|
||||
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||
|
||||
Execution steps:
|
||||
|
||||
1. Run `../scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||
- `FEATURE_DIR`
|
||||
- `FEATURE_SPEC`
|
||||
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
- Explicit out-of-scope declarations
|
||||
- User roles / personas differentiation
|
||||
|
||||
Domain & Data Model:
|
||||
- Entities, attributes, relationships
|
||||
- Identity & uniqueness rules
|
||||
- Lifecycle/state transitions
|
||||
- Data volume / scale assumptions
|
||||
|
||||
Interaction & UX Flow:
|
||||
- Critical user journeys / sequences
|
||||
- Error/empty/loading states
|
||||
- Accessibility or localization notes
|
||||
|
||||
Non-Functional Quality Attributes:
|
||||
- Performance (latency, throughput targets)
|
||||
- Scalability (horizontal/vertical, limits)
|
||||
- Reliability & availability (uptime, recovery expectations)
|
||||
- Observability (logging, metrics, tracing signals)
|
||||
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||
- Compliance / regulatory constraints (if any)
|
||||
|
||||
Integration & External Dependencies:
|
||||
- External services/APIs and failure modes
|
||||
- Data import/export formats
|
||||
- Protocol/versioning assumptions
|
||||
|
||||
Edge Cases & Failure Handling:
|
||||
- Negative scenarios
|
||||
- Rate limiting / throttling
|
||||
- Conflict resolution (e.g., concurrent edits)
|
||||
|
||||
Constraints & Tradeoffs:
|
||||
- Technical constraints (language, storage, hosting)
|
||||
- Explicit tradeoffs or rejected alternatives
|
||||
|
||||
Terminology & Consistency:
|
||||
- Canonical glossary terms
|
||||
- Avoided synonyms / deprecated terms
|
||||
|
||||
Completion Signals:
|
||||
- Acceptance criteria testability
|
||||
- Measurable Definition of Done style indicators
|
||||
|
||||
Misc / Placeholders:
|
||||
- TODO markers / unresolved decisions
|
||||
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||
|
||||
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 10 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact \* Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
- Common patterns in similar implementations
|
||||
- Risk reduction (security, performance, maintainability)
|
||||
- Alignment with any explicit project goals or constraints visible in the spec
|
||||
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||
- Then render all options as a Markdown table:
|
||||
|
||||
| Option | Description |
|
||||
| ------ | --------------------------------------------------------------------------------------------------- |
|
||||
| A | <Option A description> |
|
||||
| B | <Option B description> |
|
||||
| C | <Option C description> (add D/E as needed up to 5) |
|
||||
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||
|
||||
- For short‑answer style (no meaningful discrete options):
|
||||
- Provide your **suggested answer** based on best practices and context.
|
||||
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||
- After the user answers:
|
||||
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||
- Stop asking further questions when:
|
||||
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||
- User signals completion ("done", "good", "no more"), OR
|
||||
- You reach 5 asked questions.
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||
- Then immediately apply the clarification to the most appropriate section(s):
|
||||
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
|
||||
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. Report completion (after questioning loop ends or early termination):
|
||||
- Number of questions asked & answered.
|
||||
- Path to updated spec.
|
||||
- Sections touched (list names).
|
||||
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
|
||||
- Suggested next command.
|
||||
|
||||
Behavior rules:
|
||||
|
||||
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
|
||||
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||
- Respect user early termination signals ("stop", "done", "proceed").
|
||||
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||
|
||||
Context for prioritization: {{args}}
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: speckit-constitution
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
version: 1.9.0
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit-specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Governance Architect**. Your role is to establish and maintain the project's "Source of Law"—the constitution. You ensure that all project principles, standards, and non-negotiables are clearly documented and kept in sync across all templates and workflows.
|
||||
|
||||
## Task
|
||||
|
||||
### Outline
|
||||
|
||||
You are updating the project constitution at `AGENTS.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution template at `AGENTS.md`.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
2. Collect/derive values for placeholders:
|
||||
- If user input (conversation) supplies a value, use it.
|
||||
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||
- MINOR: New principle/section added or materially expanded guidance.
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||
|
||||
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||
- Read `.agents/skills/speckit-plan/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||
- Read `.agents/skills/speckit-specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||
- Read `.agents/skills/speckit-tasks/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
- Read each command file in `.agents/skills/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||
|
||||
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
- Version change: old → new
|
||||
- List of modified principles (old title → new title if renamed)
|
||||
- Added sections
|
||||
- Removed sections
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||
|
||||
6. Validation before final output:
|
||||
- No remaining unexplained bracket tokens.
|
||||
- Version line matches report.
|
||||
- Dates ISO format YYYY-MM-DD.
|
||||
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||
|
||||
7. Write the completed constitution back to `AGENTS.md` (overwrite).
|
||||
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any files flagged for manual follow-up.
|
||||
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||
|
||||
Formatting & Style Requirements:
|
||||
|
||||
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||
- Keep a single blank line between sections.
|
||||
- Avoid trailing whitespace.
|
||||
|
||||
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `AGENTS.md` file.
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: speckit-diff
|
||||
description: Compare two versions of a spec or plan to highlight changes.
|
||||
version: 1.9.0
|
||||
depends-on: []
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Diff Analyst**. Your role is to compare specification/plan versions and produce clear, actionable change summaries.
|
||||
|
||||
## Task
|
||||
|
||||
### Outline
|
||||
|
||||
Compare two versions of a specification artifact and produce a structured diff report.
|
||||
|
||||
### Execution Steps
|
||||
|
||||
1. **Parse Arguments**:
|
||||
- If user provides two file paths: Compare those files directly
|
||||
- If user provides one file path: Compare current version with git HEAD
|
||||
- If no arguments: Use `check-prerequisites.sh` to find current feature's spec.md and compare with HEAD
|
||||
|
||||
2. **Load Files**:
|
||||
|
||||
```bash
|
||||
# For git comparison
|
||||
git show HEAD:<relative-path> > /tmp/old_version.md
|
||||
```
|
||||
|
||||
- Read both versions into memory
|
||||
|
||||
3. **Semantic Diff Analysis**:
|
||||
Analyze changes by section:
|
||||
- **Added**: New sections, requirements, or criteria
|
||||
- **Removed**: Deleted content
|
||||
- **Modified**: Changed wording or values
|
||||
- **Moved**: Reorganized content (same meaning, different location)
|
||||
|
||||
4. **Generate Report**:
|
||||
|
||||
```markdown
|
||||
# Diff Report: [filename]
|
||||
|
||||
**Compared**: [version A] → [version B]
|
||||
**Date**: [timestamp]
|
||||
|
||||
## Summary
|
||||
|
||||
- X additions, Y removals, Z modifications
|
||||
|
||||
## Changes by Section
|
||||
|
||||
### [Section Name]
|
||||
|
||||
| Type | Content | Impact |
|
||||
| ---------- | ------------------ | ----------------- |
|
||||
| + Added | [new text] | [what this means] |
|
||||
| - Removed | [old text] | [what this means] |
|
||||
| ~ Modified | [before] → [after] | [what this means] |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- Breaking changes: [list any]
|
||||
- Scope changes: [list any]
|
||||
```
|
||||
|
||||
5. **Output**:
|
||||
- Display report in terminal (do NOT write to file unless requested)
|
||||
- Offer to save report to `FEATURE_DIR/diffs/[timestamp].md`
|
||||
|
||||
## Operating Principles
|
||||
|
||||
- **Be Precise**: Quote exact text changes
|
||||
- **Highlight Impact**: Explain what each change means for implementation
|
||||
- **Flag Breaking Changes**: Any change that invalidates existing work
|
||||
- **Ignore Whitespace**: Focus on semantic changes, not formatting
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
name: speckit-implement
|
||||
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md (with Ironclad Anti-Regression Protocols)
|
||||
version: 1.9.0
|
||||
depends-on:
|
||||
- speckit-tasks
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Master Builder**. Your role is to execute the implementation plan with precision, processing tasks from `tasks.md` and ensuring that the final codebase aligns perfectly with the specification, plan, and quality standards.
|
||||
|
||||
**CORE OBJECTIVE:** Fix bugs and implement features with **ZERO REGRESSION**.
|
||||
**YOUR MOTTO:** "Measure twice, cut once. If you can't prove it's broken, don't fix it."
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ IRONCLAD PROTOCOLS (Non-Negotiable)
|
||||
|
||||
These protocols MUST be followed for EVERY task before any production code modification:
|
||||
|
||||
### Protocol 1: Blast Radius Analysis
|
||||
|
||||
**BEFORE** writing a single line of production code modification, you MUST:
|
||||
|
||||
1. **Read**: Read the target file(s) to understand current implementation.
|
||||
2. **Trace**: Use `grep` or search tools to find ALL other files importing or using the function/class you intend to modify.
|
||||
3. **Report**: Output a precise list:
|
||||
```
|
||||
🔍 BLAST RADIUS ANALYSIS
|
||||
─────────────────────────
|
||||
Modifying: `[Function/Class X]` in `[file.ts]`
|
||||
Affected files: [A.ts, B.ts, C.ts]
|
||||
Risk Level: [LOW (<3 files) | MEDIUM (3-5 files) | HIGH (>5 files)]
|
||||
```
|
||||
4. **Decide**: If > 2 files are affected, **DO NOT MODIFY inline**. Trigger **Protocol 2 (Strangler Pattern)**.
|
||||
|
||||
### Protocol 2: Strangler Pattern (Immutable Core)
|
||||
|
||||
If a file is critical, complex, or has high dependencies (>2 affected files):
|
||||
|
||||
1. **DO NOT EDIT** the existing function inside the old file.
|
||||
2. **CREATE** a new file/module (e.g., `feature_v2.ts` or `utils_patch.ts`).
|
||||
3. **IMPLEMENT** the improved logic there.
|
||||
4. **SWITCH** the imports in the consuming files one by one.
|
||||
5. **ANNOUNCE**: "Applying Strangler Pattern to avoid regression."
|
||||
|
||||
_Benefit: If it breaks, we simply revert the import, not the whole logic._
|
||||
|
||||
### Protocol 3: Reproduction Script First (TDD)
|
||||
|
||||
You are **FORBIDDEN** from fixing a bug or implementing a feature without evidence:
|
||||
|
||||
1. Create a temporary script `repro_task_[id].ts` (or .js/.py/.go based on stack).
|
||||
2. This script MUST:
|
||||
- For bugs: **FAIL** when run against the current code (demonstrating the bug).
|
||||
- For features: **FAIL** when run against current code (feature doesn't exist).
|
||||
3. Run it and show the failure output.
|
||||
4. **ONLY THEN**, implement the fix/feature.
|
||||
5. Run the script again to prove it passes.
|
||||
6. Delete the temporary script OR convert it to a permanent test.
|
||||
|
||||
### Protocol 4: Context Anchoring
|
||||
|
||||
At the start of execution and after every 3 modifications:
|
||||
|
||||
1. Run `tree -L 2` (or equivalent) to visualize the file structure.
|
||||
2. Update `ARCHITECTURE.md` if it exists, or create it to reflect the current reality.
|
||||
|
||||
---
|
||||
|
||||
## Task Execution
|
||||
|
||||
### Outline
|
||||
|
||||
1. Run `../scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
3. Load and analyze the implementation context:
|
||||
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||
|
||||
4. **Context Anchoring (Protocol 4)**:
|
||||
- Run `tree -L 2` to visualize the current file structure
|
||||
- Document the initial state before any modifications
|
||||
|
||||
5. **Project Setup Verification**:
|
||||
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||
|
||||
**Detection & Creation Logic**:
|
||||
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||
|
||||
```sh
|
||||
git rev-parse --git-dir 2>/dev/null
|
||||
```
|
||||
|
||||
- Check if Dockerfile\* exists or Docker in plan.md → create/verify .dockerignore
|
||||
- Check if .eslintrc\* exists → create/verify .eslintignore
|
||||
- Check if eslint.config.\* exists → ensure the config's `ignores` entries cover required patterns
|
||||
- Check if .prettierrc\* exists → create/verify .prettierignore
|
||||
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||
- Check if terraform files (\*.tf) exist → create/verify .terraformignore
|
||||
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||
|
||||
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||
**If ignore file missing**: Create with full pattern set for detected technology
|
||||
|
||||
**Common Patterns by Technology** (from plan.md tech stack):
|
||||
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||
|
||||
**Tool-Specific Patterns**:
|
||||
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||
|
||||
6. Parse tasks.md structure and extract:
|
||||
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||
- **Task dependencies**: Sequential vs parallel execution rules
|
||||
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||
- **Execution flow**: Order and dependency requirements
|
||||
|
||||
7. **Execute implementation following the task plan with Ironclad Protocols**:
|
||||
|
||||
**For EACH task**, follow this sequence:
|
||||
|
||||
a. **Blast Radius Analysis (Protocol 1)**:
|
||||
- Identify all files that will be modified
|
||||
- Run `grep` to find all dependents
|
||||
- Report the blast radius
|
||||
|
||||
b. **Strategy Decision**:
|
||||
- If LOW risk (≤2 affected files): Proceed with inline modification
|
||||
- If MEDIUM/HIGH risk (>2 files): Apply Strangler Pattern (Protocol 2)
|
||||
|
||||
c. **Reproduction Script (Protocol 3)**:
|
||||
- Create `repro_task_[ID].ts` that demonstrates expected behavior
|
||||
- Run it to confirm current state (should fail for new features, or fail for bugs)
|
||||
|
||||
d. **Implementation**:
|
||||
- Execute the task according to plan
|
||||
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||
|
||||
e. **Verification**:
|
||||
- Run the reproduction script again (should now pass)
|
||||
- Run existing tests to ensure no regression
|
||||
- If any test fails: **STOP** and report the regression
|
||||
|
||||
f. **Cleanup**:
|
||||
- Delete temporary repro scripts OR convert to permanent tests
|
||||
- Mark task as complete `[X]` in tasks.md
|
||||
|
||||
8. **Progress tracking and error handling**:
|
||||
- Report progress after each completed task with this format:
|
||||
```
|
||||
✅ TASK [ID] COMPLETE
|
||||
─────────────────────
|
||||
Modified files: [list]
|
||||
Tests passed: [count]
|
||||
Blast radius: [LOW/MEDIUM/HIGH]
|
||||
```
|
||||
- Halt execution if any non-parallel task fails
|
||||
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||
- Provide clear error messages with context for debugging
|
||||
- Suggest next steps if implementation cannot proceed
|
||||
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||
|
||||
9. **Context Re-anchoring (every 3 tasks)**:
|
||||
- Run `tree -L 2` to verify file structure
|
||||
- Update ARCHITECTURE.md if structure has changed
|
||||
|
||||
10. **Completion validation**:
|
||||
- Verify all required tasks are completed
|
||||
- Check that implemented features match the original specification
|
||||
- Validate that tests pass and coverage meets requirements
|
||||
- Confirm the implementation follows the technical plan
|
||||
- Report final status with summary of completed work
|
||||
|
||||
---
|
||||
|
||||
## 🚫 Anti-Hallucination Rules
|
||||
|
||||
1. **No Magic Imports:** Never import a library or file without checking `ls` or `package.json` first.
|
||||
2. **Strict Diff-Only:** When modifying existing files, use minimal edits.
|
||||
3. **Stop & Ask:** If you find yourself editing more than 3 files for a "simple fix," **STOP**. You are likely cascading a regression. Ask for strategic guidance.
|
||||
|
||||
---
|
||||
|
||||
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: speckit-migrate
|
||||
description: Migrate existing projects into the speckit structure by generating spec.md, plan.md, and tasks.md from existing code.
|
||||
version: 1.9.0
|
||||
depends-on: []
|
||||
---
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Role
|
||||
|
||||
You are the **Antigravity Migration Specialist**. Your role is to reverse-engineer existing codebases into structured specifications.
|
||||
|
||||
## Task
|
||||
|
||||
### Outline
|
||||
|
||||
Analyze an existing codebase and generate speckit artifacts (spec.md, plan.md, tasks.md) that document what currently exists.
|
||||
|
||||
### Execution Steps
|
||||
|
||||
1. **Parse Arguments**:
|
||||
- `--path <dir>`: Directory to analyze (default: current repo root)
|
||||
- `--feature <name>`: Feature name for output directory
|
||||
- `--depth <n>`: Analysis depth (1=overview, 2=detailed, 3=exhaustive)
|
||||
|
||||
2. **Codebase Discovery**:
|
||||
|
||||
```bash
|
||||
# Get project structure
|
||||
tree -L 3 --dirsfirst -I 'node_modules|.git|dist|build' > /tmp/structure.txt
|
||||
|
||||
# Find key files
|
||||
find . -name "*.md" -o -name "package.json" -o -name "*.config.*" | head -50
|
||||
```
|
||||
|
||||
3. **Analyze Architecture**:
|
||||
- Identify framework/stack from config files
|
||||
- Map directory structure to components
|
||||
- Find entry points (main, index, app)
|
||||
- Identify data models/entities
|
||||
- Map API endpoints (if applicable)
|
||||
|
||||
4. **Generate spec.md** (reverse-engineered):
|
||||
|
||||
```markdown
|
||||
# [Feature Name] - Specification (Migrated)
|
||||
|
||||
> This specification was auto-generated from existing code.
|
||||
> Review and refine before using for future development.
|
||||
|
||||
## Overview
|
||||
|
||||
[Inferred from README, comments, and code structure]
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
[Extracted from existing functionality]
|
||||
|
||||
## Key Entities
|
||||
|
||||
[From data models, schemas, types]
|
||||
```
|
||||
|
||||
5. **Generate plan.md** (reverse-engineered):
|
||||
|
||||
```markdown
|
||||
# [Feature Name] - Technical Plan (Migrated)
|
||||
|
||||
## Current Architecture
|
||||
|
||||
[Documented from codebase analysis]
|
||||
|
||||
## Technology Stack
|
||||
|
||||
[From package.json, imports, configs]
|
||||
|
||||
## Component Map
|
||||
|
||||
[Directory → responsibility mapping]
|
||||
```
|
||||
|
||||
6. **Generate tasks.md** (completion status):
|
||||
|
||||
```markdown
|
||||
# [Feature Name] - Tasks (Migrated)
|
||||
|
||||
All tasks marked [x] represent existing implemented functionality.
|
||||
Tasks marked [ ] are inferred gaps or TODOs found in code.
|
||||
|
||||
## Existing Implementation
|
||||
|
||||
- [x] [Component A] - Implemented in `src/componentA/`
|
||||
- [x] [Component B] - Implemented in `src/componentB/`
|
||||
|
||||
## Identified Gaps
|
||||
|
||||
- [ ] [Missing tests for X]
|
||||
- [ ] [TODO comment at Y]
|
||||
```
|
||||
|
||||
7. **Output**:
|
||||
- Create feature directory: `.specify/features/[feature-name]/`
|
||||
- Write all three files
|
||||
- Report summary with confidence scores
|
||||
|
||||
## Operating Principles
|
||||
|
||||
- **Don't Invent**: Only document what exists, mark uncertainties as [INFERRED]
|
||||
- **Preserve Intent**: Use code comments and naming to understand purpose
|
||||
- **Flag TODOs**: Any TODO/FIXME/HACK in code becomes an open task
|
||||
- **Be Conservative**: When unsure, ask rather than assume
|
||||
|
||||
---
|
||||
|
||||
## LCBP3-DMS Context (MUST LOAD)
|
||||
|
||||
Before executing, load **[../_LCBP3-CONTEXT.md](../_LCBP3-CONTEXT.md)** to get:
|
||||
|
||||
- Canonical rule sources (AGENTS.md, specs/06-Decision-Records/, specs/05-Engineering-Guidelines/)
|
||||
- Tier 1 non-negotiables (ADR-019 UUID, ADR-009 schema, ADR-016 security, ADR-002 numbering, ADR-008 BullMQ, ADR-018/020 AI boundary, ADR-007 errors)
|
||||
- Domain glossary (Correspondence / RFA / Transmittal / Circulation)
|
||||
- Helper script real paths
|
||||
- Commit checklist
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user