From 6d45bdaeb55b121321b12d5125c1220fc597ec30 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 14 Apr 2026 11:13:42 +0700 Subject: [PATCH] 690414:1113 Update README.md /.agents/skills, /.windsurf/workflows --- .agent/rules/06-backend-patterns.md | 63 - .agent/rules/07-frontend-patterns.md | 54 - .agent/rules/workflows/00-speckit.all.md | 85 - .../workflows/01-speckit.constitution.md | 18 - .agent/rules/workflows/02-speckit.specify.md | 19 - .agent/rules/workflows/03-speckit.clarify.md | 18 - .agent/rules/workflows/04-speckit.plan.md | 18 - .agent/rules/workflows/05-speckit.tasks.md | 19 - .agent/rules/workflows/06-speckit.analyze.md | 22 - .../rules/workflows/07-speckit.implement.md | 20 - .agent/rules/workflows/08-speckit.checker.md | 21 - .agent/rules/workflows/09-speckit.tester.md | 21 - .agent/rules/workflows/10-speckit.reviewer.md | 19 - .agent/rules/workflows/11-speckit.validate.md | 19 - .../rules/workflows/create-backend-module.md | 51 - .../rules/workflows/create-frontend-page.md | 64 - .agent/rules/workflows/deploy.md | 71 - .agent/rules/workflows/review.md | 62 - .agent/rules/workflows/schema-change.md | 108 - .agent/rules/workflows/speckit.prepare.md | 27 - .../rules/workflows/util-speckit.checklist.md | 18 - .agent/rules/workflows/util-speckit.diff.md | 19 - .../rules/workflows/util-speckit.migrate.md | 19 - .agent/rules/workflows/util-speckit.quizme.md | 20 - .agent/rules/workflows/util-speckit.status.md | 20 - .../workflows/util-speckit.taskstoissues.md | 18 - .agents/README.md | 216 +- .../rules/00-project-context.md | 2 +- {.agent => .agents}/rules/01-adr-019-uuid.md | 0 {.agent => .agents}/rules/02-security.md | 0 {.agent => .agents}/rules/03-typescript.md | 0 .../rules/04-domain-terminology.md | 0 .../rules/05-forbidden-actions.md | 0 .../rules/06-backend-patterns.md | 0 .../rules/07-frontend-patterns.md | 0 .../rules/08-development-flow.md | 0 .../rules/09-commit-checklist.md | 0 .../rules/10-error-handling.md | 0 .../rules/11-ai-integration.md | 0 .agents/scripts/advanced-validator.js | 571 ++ .agents/scripts/bash/audit-skills.sh | 188 + .agents/scripts/bash/sync-workflows.sh | 149 + .agents/scripts/bash/validate-versions.sh | 108 + .agents/scripts/ci-hooks.ps1 | 516 ++ .agents/scripts/ci-hooks.sh | 445 ++ .agents/scripts/dependency-validator.js | 457 ++ .agents/scripts/health-monitor.js | 369 ++ .agents/scripts/performance-monitor.js | 494 ++ .agents/scripts/powershell/audit-skills.ps1 | 203 + .../scripts/powershell/validate-versions.ps1 | 112 + .agents/skills/VERSION | 10 +- .agents/skills/skills.md | 105 + .agents/tests/skill-integration.test.js | 241 + .agents/tests/workflow-validation.test.js | 235 + .agents/workflows/00-speckit-all.md | 85 - .agents/workflows/01-speckit-constitution.md | 18 - .agents/workflows/02-speckit-specify.md | 19 - .agents/workflows/03-speckit-clarify.md | 18 - .agents/workflows/04-speckit-plan.md | 18 - .agents/workflows/05-speckit-tasks.md | 19 - .agents/workflows/06-speckit-analyze.md | 22 - .agents/workflows/07-speckit-implement.md | 20 - .agents/workflows/08-speckit-checker.md | 21 - .agents/workflows/09-speckit-tester.md | 21 - .agents/workflows/10-speckit-reviewer.md | 19 - .agents/workflows/11-speckit-validate.md | 19 - .agents/workflows/create-backend-module.md | 51 - .agents/workflows/create-frontend-page.md | 64 - .agents/workflows/deploy.md | 71 - .agents/workflows/review.md | 62 - .agents/workflows/schema-change.md | 108 - .agents/workflows/speckit-prepare.md | 27 - .agents/workflows/util-speckit-checklist.md | 18 - .agents/workflows/util-speckit-diff.md | 19 - .agents/workflows/util-speckit-migrate.md | 19 - .agents/workflows/util-speckit-quizme.md | 20 - .agents/workflows/util-speckit-status.md | 20 - .../workflows/util-speckit-taskstoissues.md | 18 - .husky/pre-commit | 2 +- .windsurf/rules/00-project-context.md | 60 - .windsurf/rules/01-adr-019-uuid.md | 71 - .windsurf/rules/02-security.md | 36 - .windsurf/rules/03-typescript.md | 32 - .windsurf/rules/04-domain-terminology.md | 38 - .windsurf/rules/05-forbidden-actions.md | 41 - .windsurf/rules/08-development-flow.md | 42 - .windsurf/rules/09-commit-checklist.md | 36 - .windsurf/rules/10-error-handling.md | 78 - .windsurf/rules/11-ai-integration.md | 100 - .windsurf/workflows/00-speckit.all.md | 1 + .../workflows/01-speckit.constitution.md | 1 + .windsurf/workflows/02-speckit.specify.md | 1 + .windsurf/workflows/03-speckit.clarify.md | 1 + .windsurf/workflows/04-speckit.plan.md | 1 + .windsurf/workflows/05-speckit.tasks.md | 1 + .windsurf/workflows/06-speckit.analyze.md | 1 + .windsurf/workflows/07-speckit.implement.md | 1 + .windsurf/workflows/08-speckit.checker.md | 1 + .windsurf/workflows/09-speckit.tester.md | 1 + .windsurf/workflows/10-speckit.reviewer.md | 1 + .windsurf/workflows/11-speckit.validate.md | 1 + .windsurf/workflows/create-backend-module.md | 15 +- .windsurf/workflows/create-frontend-page.md | 1 + .windsurf/workflows/deploy.md | 1 + .windsurf/workflows/schema-change.md | 9 +- .windsurf/workflows/speckit.prepare.md | 1 + .windsurf/workflows/util-speckit.checklist.md | 1 + .windsurf/workflows/util-speckit.diff.md | 1 + .windsurf/workflows/util-speckit.migrate.md | 1 + .windsurf/workflows/util-speckit.quizme.md | 1 + .windsurf/workflows/util-speckit.status.md | 1 + .../workflows/util-speckit.taskstoissues.md | 18 - .windsurfrules | 390 -- AGENTS.md | 98 +- CHANGELOG.md | 98 + CONTRIBUTING.md | 51 +- README.md | 289 +- backend/jest.config.js | 4 + .../entities/attachment.entity.ts | 15 + .../file-storage.controller.spec.ts | 10 +- .../file-storage/file-storage.controller.ts | 28 + .../file-storage/file-storage.service.ts | 25 + backend/src/common/guards/rbac.guard.spec.ts | 304 + .../audit-log.interceptor.spec.ts | 484 ++ .../idempotency.interceptor.spec.ts | 369 ++ .../interceptors/idempotency.interceptor.ts | 14 +- .../performance.interceptor.spec.ts | 486 ++ .../transform.interceptor.spec.ts | 387 ++ backend/src/common/utils/uuid-guard.spec.ts | 145 + .../circulation/circulation.controller.ts | 52 +- .../circulation/circulation.service.spec.ts | 249 + .../circulation/circulation.service.ts | 118 +- .../dto/force-close-circulation.dto.ts | 10 + .../circulation/dto/reassign-routing.dto.ts | 11 + .../entities/circulation.entity.ts | 3 + .../transmittal/transmittal.controller.ts | 23 +- .../modules/transmittal/transmittal.module.ts | 4 + .../transmittal/transmittal.service.spec.ts | 261 + .../transmittal/transmittal.service.ts | 118 +- .../dto/workflow-history-item.dto.ts | 19 + .../dto/workflow-transition.dto.ts | 22 +- .../entities/workflow-history.entity.ts | 10 + .../guards/workflow-transition.guard.ts | 86 + .../workflow-engine.controller.ts | 58 +- .../workflow-engine/workflow-engine.module.ts | 13 +- .../workflow-engine.service.spec.ts | 10 + .../workflow-engine.service.ts | 116 +- .../(dashboard)/circulation/[uuid]/page.tsx | 107 +- .../correspondences/[uuid]/page.tsx | 81 +- frontend/app/(dashboard)/rfas/[uuid]/page.tsx | 89 +- .../(dashboard)/transmittals/[uuid]/page.tsx | 87 +- .../app/(dashboard)/transmittals/page.tsx | 41 +- .../components/common/file-preview-modal.tsx | 165 + .../common/workflow-error-boundary.tsx | 41 + frontend/components/correspondences/form.tsx | 2 +- frontend/components/rfas/form.tsx | 2 +- .../components/workflow/integrated-banner.tsx | 235 + .../workflow/workflow-lifecycle.tsx | 309 + frontend/hooks/use-circulation.ts | 25 + frontend/hooks/use-correspondence.ts | 3 + frontend/hooks/use-rfa.ts | 3 + frontend/hooks/use-translations.ts | 13 + frontend/hooks/use-transmittal.ts | 33 + frontend/hooks/use-workflow-action.ts | 50 + frontend/hooks/use-workflow-history.ts | 24 + frontend/lib/i18n/index.ts | 27 + frontend/lib/services/transmittal.service.ts | 9 + .../lib/services/workflow-engine.service.ts | 34 +- frontend/public/locales/en/common.json | 40 + frontend/public/locales/th/common.json | 40 + frontend/types/circulation.ts | 5 + frontend/types/correspondence.ts | 6 + .../workflow-engine/workflow-engine.dto.ts | 15 + frontend/types/rfa.ts | 6 + frontend/types/transmittal.ts | 5 + frontend/types/workflow.ts | 24 + specs/.vscode/settings.json | 3 - specs/001-transmittals-circulation/plan.md | 333 + specs/001-transmittals-circulation/spec.md | 183 + specs/001-transmittals-circulation/tasks.md | 182 + .../03-01-data-dictionary.md | 8 +- ...add-workflow-history-id-to-attachments.sql | 25 + .../deltas/05-add-circulation-deadline.sql | 13 + .../lcbp3-v1.8.0-schema-02-tables.sql | 9 +- .../lcbp3-v1.8.0-schema-03-views-indexes.sql | 3 + ...ADR-021-integrated-workflow-context.md .md | 224 + .../contracts/workflow-transition.yaml | 355 + .../ADR-021-workflow-context/data-model.md | 285 + .../08-Tasks/ADR-021-workflow-context/plan.md | 301 + .../ADR-021-workflow-context/quickstart.md | 402 ++ .../ADR-021-workflow-context/research.md | 216 + .../ADR-021-workflow-context/tasks.md | 331 + .../nestjs-best-practices-original/AGENTS.md | 5863 ----------------- .../plan.md | 105 + 194 files changed, 12708 insertions(+), 8762 deletions(-) delete mode 100644 .agent/rules/06-backend-patterns.md delete mode 100644 .agent/rules/07-frontend-patterns.md delete mode 100644 .agent/rules/workflows/00-speckit.all.md delete mode 100644 .agent/rules/workflows/01-speckit.constitution.md delete mode 100644 .agent/rules/workflows/02-speckit.specify.md delete mode 100644 .agent/rules/workflows/03-speckit.clarify.md delete mode 100644 .agent/rules/workflows/04-speckit.plan.md delete mode 100644 .agent/rules/workflows/05-speckit.tasks.md delete mode 100644 .agent/rules/workflows/06-speckit.analyze.md delete mode 100644 .agent/rules/workflows/07-speckit.implement.md delete mode 100644 .agent/rules/workflows/08-speckit.checker.md delete mode 100644 .agent/rules/workflows/09-speckit.tester.md delete mode 100644 .agent/rules/workflows/10-speckit.reviewer.md delete mode 100644 .agent/rules/workflows/11-speckit.validate.md delete mode 100644 .agent/rules/workflows/create-backend-module.md delete mode 100644 .agent/rules/workflows/create-frontend-page.md delete mode 100644 .agent/rules/workflows/deploy.md delete mode 100644 .agent/rules/workflows/review.md delete mode 100644 .agent/rules/workflows/schema-change.md delete mode 100644 .agent/rules/workflows/speckit.prepare.md delete mode 100644 .agent/rules/workflows/util-speckit.checklist.md delete mode 100644 .agent/rules/workflows/util-speckit.diff.md delete mode 100644 .agent/rules/workflows/util-speckit.migrate.md delete mode 100644 .agent/rules/workflows/util-speckit.quizme.md delete mode 100644 .agent/rules/workflows/util-speckit.status.md delete mode 100644 .agent/rules/workflows/util-speckit.taskstoissues.md rename {.agent => .agents}/rules/00-project-context.md (98%) rename {.agent => .agents}/rules/01-adr-019-uuid.md (100%) rename {.agent => .agents}/rules/02-security.md (100%) rename {.agent => .agents}/rules/03-typescript.md (100%) rename {.agent => .agents}/rules/04-domain-terminology.md (100%) rename {.agent => .agents}/rules/05-forbidden-actions.md (100%) rename {.windsurf => .agents}/rules/06-backend-patterns.md (100%) rename {.windsurf => .agents}/rules/07-frontend-patterns.md (100%) rename {.agent => .agents}/rules/08-development-flow.md (100%) rename {.agent => .agents}/rules/09-commit-checklist.md (100%) rename {.agent => .agents}/rules/10-error-handling.md (100%) rename {.agent => .agents}/rules/11-ai-integration.md (100%) create mode 100644 .agents/scripts/advanced-validator.js create mode 100644 .agents/scripts/bash/audit-skills.sh create mode 100644 .agents/scripts/bash/sync-workflows.sh create mode 100644 .agents/scripts/bash/validate-versions.sh create mode 100644 .agents/scripts/ci-hooks.ps1 create mode 100644 .agents/scripts/ci-hooks.sh create mode 100644 .agents/scripts/dependency-validator.js create mode 100644 .agents/scripts/health-monitor.js create mode 100644 .agents/scripts/performance-monitor.js create mode 100644 .agents/scripts/powershell/audit-skills.ps1 create mode 100644 .agents/scripts/powershell/validate-versions.ps1 create mode 100644 .agents/skills/skills.md create mode 100644 .agents/tests/skill-integration.test.js create mode 100644 .agents/tests/workflow-validation.test.js delete mode 100644 .agents/workflows/00-speckit-all.md delete mode 100644 .agents/workflows/01-speckit-constitution.md delete mode 100644 .agents/workflows/02-speckit-specify.md delete mode 100644 .agents/workflows/03-speckit-clarify.md delete mode 100644 .agents/workflows/04-speckit-plan.md delete mode 100644 .agents/workflows/05-speckit-tasks.md delete mode 100644 .agents/workflows/06-speckit-analyze.md delete mode 100644 .agents/workflows/07-speckit-implement.md delete mode 100644 .agents/workflows/08-speckit-checker.md delete mode 100644 .agents/workflows/09-speckit-tester.md delete mode 100644 .agents/workflows/10-speckit-reviewer.md delete mode 100644 .agents/workflows/11-speckit-validate.md delete mode 100644 .agents/workflows/create-backend-module.md delete mode 100644 .agents/workflows/create-frontend-page.md delete mode 100644 .agents/workflows/deploy.md delete mode 100644 .agents/workflows/review.md delete mode 100644 .agents/workflows/schema-change.md delete mode 100644 .agents/workflows/speckit-prepare.md delete mode 100644 .agents/workflows/util-speckit-checklist.md delete mode 100644 .agents/workflows/util-speckit-diff.md delete mode 100644 .agents/workflows/util-speckit-migrate.md delete mode 100644 .agents/workflows/util-speckit-quizme.md delete mode 100644 .agents/workflows/util-speckit-status.md delete mode 100644 .agents/workflows/util-speckit-taskstoissues.md delete mode 100644 .windsurf/rules/00-project-context.md delete mode 100644 .windsurf/rules/01-adr-019-uuid.md delete mode 100644 .windsurf/rules/02-security.md delete mode 100644 .windsurf/rules/03-typescript.md delete mode 100644 .windsurf/rules/04-domain-terminology.md delete mode 100644 .windsurf/rules/05-forbidden-actions.md delete mode 100644 .windsurf/rules/08-development-flow.md delete mode 100644 .windsurf/rules/09-commit-checklist.md delete mode 100644 .windsurf/rules/10-error-handling.md delete mode 100644 .windsurf/rules/11-ai-integration.md delete mode 100644 .windsurf/workflows/util-speckit.taskstoissues.md delete mode 100644 .windsurfrules create mode 100644 backend/src/common/guards/rbac.guard.spec.ts create mode 100644 backend/src/common/interceptors/audit-log.interceptor.spec.ts create mode 100644 backend/src/common/interceptors/idempotency.interceptor.spec.ts create mode 100644 backend/src/common/interceptors/performance.interceptor.spec.ts create mode 100644 backend/src/common/interceptors/transform.interceptor.spec.ts create mode 100644 backend/src/common/utils/uuid-guard.spec.ts create mode 100644 backend/src/modules/circulation/circulation.service.spec.ts create mode 100644 backend/src/modules/circulation/dto/force-close-circulation.dto.ts create mode 100644 backend/src/modules/circulation/dto/reassign-routing.dto.ts create mode 100644 backend/src/modules/transmittal/transmittal.service.spec.ts create mode 100644 backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts create mode 100644 backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts create mode 100644 frontend/components/common/file-preview-modal.tsx create mode 100644 frontend/components/common/workflow-error-boundary.tsx create mode 100644 frontend/components/workflow/integrated-banner.tsx create mode 100644 frontend/components/workflow/workflow-lifecycle.tsx create mode 100644 frontend/hooks/use-translations.ts create mode 100644 frontend/hooks/use-transmittal.ts create mode 100644 frontend/hooks/use-workflow-action.ts create mode 100644 frontend/hooks/use-workflow-history.ts create mode 100644 frontend/lib/i18n/index.ts create mode 100644 frontend/public/locales/en/common.json create mode 100644 frontend/public/locales/th/common.json delete mode 100644 specs/.vscode/settings.json create mode 100644 specs/001-transmittals-circulation/plan.md create mode 100644 specs/001-transmittals-circulation/spec.md create mode 100644 specs/001-transmittals-circulation/tasks.md create mode 100644 specs/03-Data-and-Storage/deltas/04-add-workflow-history-id-to-attachments.sql create mode 100644 specs/03-Data-and-Storage/deltas/05-add-circulation-deadline.sql create mode 100644 specs/06-Decision-Records/ADR-021-integrated-workflow-context.md .md create mode 100644 specs/08-Tasks/ADR-021-workflow-context/contracts/workflow-transition.yaml create mode 100644 specs/08-Tasks/ADR-021-workflow-context/data-model.md create mode 100644 specs/08-Tasks/ADR-021-workflow-context/plan.md create mode 100644 specs/08-Tasks/ADR-021-workflow-context/quickstart.md create mode 100644 specs/08-Tasks/ADR-021-workflow-context/research.md create mode 100644 specs/08-Tasks/ADR-021-workflow-context/tasks.md delete mode 100644 specs/99-archives/skills-backup/nestjs-best-practices-original/AGENTS.md create mode 100644 specs/feat/adr-021-integrated-workflow-context/plan.md diff --git a/.agent/rules/06-backend-patterns.md b/.agent/rules/06-backend-patterns.md deleted file mode 100644 index f9c6095..0000000 --- a/.agent/rules/06-backend-patterns.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -trigger: always_on -globs: - - "backend/**/*.service.ts" - - "backend/**/*.controller.ts" - - "backend/**/*.dto.ts" - - "backend/**/*.entity.ts" ---- - -# Backend Patterns (NestJS) - -## Architecture - -- **Thin Controller** — business logic in Service layer -- **DTO Validation** — class-validator + class-transformer -- **RBAC** — CASL for authorization -- **Error Handling** — Logger + HttpException - -## UUID Resolution Pattern - -```typescript -// Controller - accept UUID in DTO -@Post() -async create(@Body() dto: CreateCorrespondenceDto) { - // Resolve UUID to internal ID - const contract = await this.contractService.findOneByUuid(dto.contractUuid); - const contractId = contract.id; // Internal INT for DB queries - - return this.service.create(dto, contractId); -} - -// Service - use internal ID for DB operations -async create(dto: CreateCorrespondenceDto, contractId: number) { - // Use contractId (INT) for database queries - const correspondence = this.repo.create({ - contractId, // FK is INT - // ... other fields - }); - return this.repo.save(correspondence); -} -``` - -## API Response Pattern - -```typescript -// Entity -@Entity() -class Contract extends UuidBaseEntity { - @Column({ type: 'uuid' }) - publicId: string; - - @PrimaryKey() - @Exclude() - id: number; -} - -// Response automatically includes publicId as 'id' -// { id: "019505a1-7c3e-7000-8000-abc123def456", ... } -``` - -## Full Guidelines - -`specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` diff --git a/.agent/rules/07-frontend-patterns.md b/.agent/rules/07-frontend-patterns.md deleted file mode 100644 index 69c0ee2..0000000 --- a/.agent/rules/07-frontend-patterns.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -trigger: always_on -globs: - - "frontend/**/*.tsx" - - "frontend/**/*.ts" - - "frontend/**/*.css" ---- - -# Frontend Patterns (Next.js) - -## Form Handling - -- **RHF** (React Hook Form) for form management -- **Zod** for validation schema -- **TanStack Query** for server state - -## UUID Handling - -```typescript -// ✅ CORRECT - Use publicId only -interface ProjectOption { - publicId?: string; - projectName?: string; -} - -// Select options -const options = contracts.map(c => ({ - label: `${c.contractName} (${c.contractCode})`, - value: c.publicId!, // Use publicId, no fallback to id -})); - -// ❌ WRONG - Never use these patterns -const value = c.publicId ?? c.id ?? ''; // Wrong! -const id = parseInt(projectId); // Wrong - parseInt on UUID! -``` - -## API Client Pattern - -```typescript -// Use publicId directly in API calls -const contract = await contractService.getById(publicId); - -// Form submission with UUID -const onSubmit = async (data: FormData) => { - await correspondenceService.create({ - contractUuid: selectedContract.publicId!, // UUID string - // ... other fields - }); -}; -``` - -## Full Guidelines - -`specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` diff --git a/.agent/rules/workflows/00-speckit.all.md b/.agent/rules/workflows/00-speckit.all.md deleted file mode 100644 index d9736e7..0000000 --- a/.agent/rules/workflows/00-speckit.all.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -description: Run the full speckit pipeline from specification to analysis in one command. ---- - -# Workflow: speckit.all - -This meta-workflow orchestrates the **complete development lifecycle**, from specification through implementation and validation. For the preparation-only pipeline (steps 1-5), use `/speckit.prepare` instead. - -## Preparation Phase (Steps 1-5) - -1. **Specify** (`/speckit.specify`): - - Use the `view_file` tool to read: `.agents/skills/speckit.specify/SKILL.md` - - Execute with user's feature description - - Creates: `spec.md` - -2. **Clarify** (`/speckit.clarify`): - - Use the `view_file` tool to read: `.agents/skills/speckit.clarify/SKILL.md` - - Execute to resolve ambiguities - - Updates: `spec.md` - -3. **Plan** (`/speckit.plan`): - - Use the `view_file` tool to read: `.agents/skills/speckit.plan/SKILL.md` - - Execute to create technical design - - Creates: `plan.md` - -4. **Tasks** (`/speckit.tasks`): - - Use the `view_file` tool to read: `.agents/skills/speckit.tasks/SKILL.md` - - Execute to generate task breakdown - - Creates: `tasks.md` - -5. **Analyze** (`/speckit.analyze`): - - Use the `view_file` tool to read: `.agents/skills/speckit.analyze/SKILL.md` - - Execute to validate consistency across spec, plan, and tasks - - Output: Analysis report - - **Gate**: If critical issues found, stop and fix before proceeding - -## Implementation Phase (Steps 6-7) - -6. **Implement** (`/speckit.implement`): - - Use the `view_file` tool to read: `.agents/skills/speckit.implement/SKILL.md` - - Execute all tasks from `tasks.md` with anti-regression protocols - - Output: Working implementation - -7. **Check** (`/speckit.checker`): - - Use the `view_file` tool to read: `.agents/skills/speckit.checker/SKILL.md` - - Run static analysis (linters, type checkers, security scanners) - - Output: Checker report - -## Verification Phase (Steps 8-10) - -8. **Test** (`/speckit.tester`): - - Use the `view_file` tool to read: `.agents/skills/speckit.tester/SKILL.md` - - Run tests with coverage - - Output: Test + coverage report - -9. **Review** (`/speckit.reviewer`): - - Use the `view_file` tool to read: `.agents/skills/speckit.reviewer/SKILL.md` - - Perform code review - - Output: Review report with findings - -10. **Validate** (`/speckit.validate`): - - Use the `view_file` tool to read: `.agents/skills/speckit.validate/SKILL.md` - - Verify implementation matches spec requirements - - Output: Validation report (pass/fail) - -## Usage - -``` -/speckit.all "Build a user authentication system with OAuth2 support" -``` - -## Pipeline Comparison - -| Pipeline | Steps | Use When | -| ------------------ | ------------------------- | -------------------------------------- | -| `/speckit.prepare` | 1-5 (Specify → Analyze) | Planning only — you'll implement later | -| `/speckit.all` | 1-10 (Specify → Validate) | Full lifecycle in one pass | - -## On Error - -If any step fails, stop the pipeline and report: - -- Which step failed -- The error message -- Suggested remediation (e.g., "Run `/speckit.clarify` to resolve ambiguities before continuing") diff --git a/.agent/rules/workflows/01-speckit.constitution.md b/.agent/rules/workflows/01-speckit.constitution.md deleted file mode 100644 index 96544a0..0000000 --- a/.agent/rules/workflows/01-speckit.constitution.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. ---- - -# Workflow: speckit.constitution - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.constitution/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `.specify/` directory doesn't exist: Initialize the speckit structure first diff --git a/.agent/rules/workflows/02-speckit.specify.md b/.agent/rules/workflows/02-speckit.specify.md deleted file mode 100644 index 69fd061..0000000 --- a/.agent/rules/workflows/02-speckit.specify.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Create or update the feature specification from a natural language feature description. ---- - -# Workflow: speckit.specify - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - - This is typically the starting point of a new feature. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.specify/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the feature description for the skill's logic. - -4. **On Error**: - - If no feature description provided: Ask the user to describe the feature they want to specify diff --git a/.agent/rules/workflows/03-speckit.clarify.md b/.agent/rules/workflows/03-speckit.clarify.md deleted file mode 100644 index 9217be3..0000000 --- a/.agent/rules/workflows/03-speckit.clarify.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -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. ---- - -# Workflow: speckit.clarify - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.clarify/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit.specify` first to create the feature specification diff --git a/.agent/rules/workflows/04-speckit.plan.md b/.agent/rules/workflows/04-speckit.plan.md deleted file mode 100644 index 456b83c..0000000 --- a/.agent/rules/workflows/04-speckit.plan.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Execute the implementation planning workflow using the plan template to generate design artifacts. ---- - -# Workflow: speckit.plan - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.plan/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit.specify` first to create the feature specification diff --git a/.agent/rules/workflows/05-speckit.tasks.md b/.agent/rules/workflows/05-speckit.tasks.md deleted file mode 100644 index 54967d0..0000000 --- a/.agent/rules/workflows/05-speckit.tasks.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. ---- - -# Workflow: speckit.tasks - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.tasks/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `plan.md` is missing: Run `/speckit.plan` first - - If `spec.md` is missing: Run `/speckit.specify` first diff --git a/.agent/rules/workflows/06-speckit.analyze.md b/.agent/rules/workflows/06-speckit.analyze.md deleted file mode 100644 index a177a65..0000000 --- a/.agent/rules/workflows/06-speckit.analyze.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. ---- - -// turbo-all - -# Workflow: speckit.analyze - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.analyze/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit.specify` first - - If `plan.md` is missing: Run `/speckit.plan` first - - If `tasks.md` is missing: Run `/speckit.tasks` first diff --git a/.agent/rules/workflows/07-speckit.implement.md b/.agent/rules/workflows/07-speckit.implement.md deleted file mode 100644 index 9a23850..0000000 --- a/.agent/rules/workflows/07-speckit.implement.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Execute the implementation plan by processing and executing all tasks defined in tasks.md ---- - -# Workflow: speckit.implement - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.implement/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit.tasks` first - - If `plan.md` is missing: Run `/speckit.plan` first - - If `spec.md` is missing: Run `/speckit.specify` first diff --git a/.agent/rules/workflows/08-speckit.checker.md b/.agent/rules/workflows/08-speckit.checker.md deleted file mode 100644 index 821544b..0000000 --- a/.agent/rules/workflows/08-speckit.checker.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Run static analysis tools and aggregate results. ---- - -// turbo-all - -# Workflow: speckit.checker - -1. **Context Analysis**: - - The user may specify paths to check or run on entire project. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.checker/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no linting tools available: Report which tools to install based on project type - - If tools fail: Show raw error and suggest config fixes diff --git a/.agent/rules/workflows/09-speckit.tester.md b/.agent/rules/workflows/09-speckit.tester.md deleted file mode 100644 index 80f1eab..0000000 --- a/.agent/rules/workflows/09-speckit.tester.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Execute tests, measure coverage, and report results. ---- - -// turbo-all - -# Workflow: speckit.tester - -1. **Context Analysis**: - - The user may specify test paths, options, or just run all tests. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.tester/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no test framework detected: Report "No test framework found. Install Jest, Vitest, Pytest, or similar." - - If tests fail: Show failure details and suggest fixes diff --git a/.agent/rules/workflows/10-speckit.reviewer.md b/.agent/rules/workflows/10-speckit.reviewer.md deleted file mode 100644 index e5e18ef..0000000 --- a/.agent/rules/workflows/10-speckit.reviewer.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Perform code review with actionable feedback and suggestions. ---- - -# Workflow: speckit.reviewer - -1. **Context Analysis**: - - The user may specify files to review, "staged" for git staged changes, or "branch" for branch diff. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.reviewer/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no files to review: Ask user to stage changes or specify file paths - - If not a git repo: Review current directory files instead diff --git a/.agent/rules/workflows/11-speckit.validate.md b/.agent/rules/workflows/11-speckit.validate.md deleted file mode 100644 index fbc20d1..0000000 --- a/.agent/rules/workflows/11-speckit.validate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Validate that implementation matches specification requirements. ---- - -# Workflow: speckit.validate - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.validate/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit.tasks` first - - If implementation not started: Run `/speckit.implement` first diff --git a/.agent/rules/workflows/create-backend-module.md b/.agent/rules/workflows/create-backend-module.md deleted file mode 100644 index 78cf13d..0000000 --- a/.agent/rules/workflows/create-backend-module.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -description: Create a new NestJS backend feature module following project standards ---- - -# Create NestJS Backend Module - -Use this workflow when creating a new feature module in `backend/src/modules/`. -Follows `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` and ADR-005. - -## Steps - -// turbo - -1. **Verify requirements exist** — confirm the feature is in `specs/01-Requirements/` before starting - -// turbo 2. **Check schema** — read `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql` for relevant tables - -3. **Scaffold module folder** - -``` -backend/src/modules// -├── .module.ts -├── .controller.ts -├── .service.ts -├── dto/ -│ ├── create-.dto.ts -│ └── update-.dto.ts -├── entities/ -│ └── .entity.ts -└── .controller.spec.ts -``` - -4. **Create Entity** — map ONLY columns defined in the schema SQL. Use TypeORM decorators. Add `@VersionColumn()` if the entity needs optimistic locking. - -5. **Create DTOs** — use `class-validator` decorators. Never use `any`. Validate all inputs. - -6. **Create Service** — inject repository via constructor DI. Use transactions for multi-step writes. Add `Idempotency-Key` guard for POST/PUT/PATCH operations. - -7. **Create Controller** — apply `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`. Use proper HTTP status codes. Document with `@ApiTags` and `@ApiOperation`. - -8. **Register in Module** — add to `imports`, `providers`, `controllers`, `exports` as needed. - -9. **Register in AppModule** — import the new module in `app.module.ts`. - -// turbo 10. **Write unit test** — cover service methods with Jest mocks. Run: - -```bash -pnpm test:watch -``` - -// turbo 11. **Citation** — confirm implementation references `specs/01-Requirements/` and `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` diff --git a/.agent/rules/workflows/create-frontend-page.md b/.agent/rules/workflows/create-frontend-page.md deleted file mode 100644 index 22c4b2e..0000000 --- a/.agent/rules/workflows/create-frontend-page.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -description: Create a new Next.js App Router page following project standards ---- - -# Create Next.js Frontend Page - -Use this workflow when creating a new page in `frontend/app/`. -Follows `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md`, ADR-011, ADR-012, ADR-013, ADR-014. - -## Steps - -1. **Determine route** — decide the route path, e.g. `app/(dashboard)/documents/page.tsx` - -2. **Classify components** — decide what is Server Component (default) vs Client Component (`'use client'`) - - Server Component: initial data load, static content, SEO - - Client Component: interactivity, forms, TanStack Query hooks, Zustand - -3. **Create page file** — Server Component by default: - -```typescript -// app/(dashboard)//page.tsx -import { Metadata } from 'next'; - -export const metadata: Metadata = { - title: ' | LCBP3-DMS', -}; - -export default async function Page() { - return ( -
- {/* Page content */} -
- ); -} -``` - -4. **Create API hook** (if client-side data needed) — add to `hooks/use-.ts`: - -```typescript -'use client'; -import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api-client'; - -export function use() { - return useQuery({ - queryKey: [''], - queryFn: () => apiClient.get(''), - }); -} -``` - -5. **Build UI components** — use Shadcn/UI primitives. Place reusable components in `components//`. - -6. **Handle forms** — use React Hook Form + Zod schema validation. Never access form values without validation. - -7. **Handle errors** — add `error.tsx` alongside `page.tsx` for route-level error boundaries. - -8. **Add loading state** — add `loading.tsx` for Suspense fallback if page does async work. - -9. **Add to navigation** — update sidebar/nav config if the page should appear in the menu. - -10. **Access control** — ensure page checks CASL permissions. Redirect unauthorized users via middleware or `notFound()`. - -11. **Citation** — confirm implementation references `specs/01-Requirements/` and `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` diff --git a/.agent/rules/workflows/deploy.md b/.agent/rules/workflows/deploy.md deleted file mode 100644 index 4067162..0000000 --- a/.agent/rules/workflows/deploy.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: Deploy the application via Gitea Actions to QNAP Container Station ---- - -# Deploy to Production - -Use this workflow to deploy updated backend and/or frontend to QNAP via Gitea Actions CI/CD. -Follows `specs/04-Infrastructure-OPS/` and ADR-015. - -## Pre-deployment Checklist - -- [ ] All tests pass locally (`pnpm test:watch`) -- [ ] No TypeScript errors (`tsc --noEmit`) -- [ ] No `any` types introduced -- [ ] Schema changes applied to `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql` -- [ ] Environment variables documented (NOT in `.env` files) - -## Steps - -1. **Commit and push to Gitea** - -```bash -git status -git add . -git commit -m "feat(): " -git push origin main -``` - -2. **Monitor Gitea Actions** — open Gitea web UI → Actions tab → verify pipeline starts - -3. **Pipeline stages (automatic)** - - `build-backend` → Docker image build + push to registry - - `build-frontend` → Docker image build + push to registry - - `deploy` → SSH to QNAP → `docker compose pull` + `docker compose up -d` - -4. **Verify backend health** - -```bash -curl http://:3000/health -# Expected: { "status": "ok" } -``` - -5. **Verify frontend** - -```bash -curl -I http://:3001 -# Expected: HTTP 200 -``` - -6. **Check logs in Grafana** — navigate to Grafana → Loki → filter by container name - - Backend: `container_name="lcbp3-backend"` - - Frontend: `container_name="lcbp3-frontend"` - -7. **Verify database** — confirm schema changes are reflected (if any) - -8. **Rollback (if needed)** - -```bash -# SSH into QNAP -docker compose pull = -docker compose up -d -``` - -## Common Issues - -| Symptom | Cause | Fix | -| ----------------- | --------------------- | ----------------------------------- | -| Backend unhealthy | DB connection failed | Check MariaDB container + env vars | -| Frontend blank | Build error | Check Next.js build logs in Grafana | -| 502 Bad Gateway | Container not started | `docker compose ps` to check status | -| Pipeline stuck | Gitea runner offline | Restart runner on QNAP | diff --git a/.agent/rules/workflows/review.md b/.agent/rules/workflows/review.md deleted file mode 100644 index 37fc514..0000000 --- a/.agent/rules/workflows/review.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -auto_execution_mode: 0 -description: Review code changes for bugs, security issues, and improvements ---- - -You are a senior software engineer performing a thorough code review to identify potential bugs. - -Your task is to find all potential bugs and code improvements in the code changes. Focus on: - -1. Logic errors and incorrect behavior -2. Edge cases that aren't handled -3. Null/undefined reference issues -4. Race conditions or concurrency issues -5. Security vulnerabilities -6. Improper resource management or resource leaks -7. API contract violations -8. Incorrect caching behavior, including cache staleness issues, cache key-related bugs, incorrect cache invalidation, and ineffective caching -9. Violations of existing code patterns or conventions - -## 🔴 Tier 1 Critical Rules (CI Blockers) - -The following are **CI-blocking issues** that must be caught in code review. These align with project specs in `specs/05-Engineering-Guidelines/` and `specs/06-Decision-Records/`: - -### ADR-019: UUID Handling - -- **❌ NEVER use `parseInt()`, `Number()`, or `+` operator on UUID values** - - Example of violation: `parseInt(projectId)` where `projectId` is UUID string - - ✅ Correct: Use UUID string directly without conversion -- **❌ NEVER expose internal INT PK in API responses** - - API must expose only `publicId` (transformed to `id` via `@Expose()`) - - Verify DTOs have `@Exclude()` on `id: number` field - -### TypeScript Strict Rules - -- **❌ ZERO `any` types allowed** — use proper types or `unknown` + narrowing -- **❌ ZERO `console.log`** — must use NestJS `Logger` (backend) or remove (frontend) -- **❌ NO `req: any` in controllers** — use `RequestWithUser` typed interface - -### Database & Architecture - -- **❌ NO SQL Triggers for business logic** — use NestJS Service methods instead -- **❌ NO `.env` files in production** — use Docker environment variables -- **❌ NO direct table/column name invention** — verify against `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` - -### Security (ADR-016) - -- Idempotency validation for critical `POST`/`PUT`/`PATCH` endpoints -- Two-phase file upload pattern (Upload → Temp → Commit → Permanent) -- Input validation with class-validator (backend) and Zod (frontend) - -### Test Coverage Requirements - -- **Backend Services:** 80% minimum -- **Backend Overall:** 70% minimum -- **Business Logic:** 80% minimum - -Make sure to: - -1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring. -2. If you find any pre-existing bugs in the code, you should also report those since it's important for us to maintain general code quality for the user. -3. Do NOT report issues that are speculative or low-confidence. All your conclusions should be based on a complete understanding of the codebase. -4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different. diff --git a/.agent/rules/workflows/schema-change.md b/.agent/rules/workflows/schema-change.md deleted file mode 100644 index ef5afb2..0000000 --- a/.agent/rules/workflows/schema-change.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -description: Manage database schema changes following ADR-009 (no migrations, modify SQL directly) ---- - -# Schema Change Workflow - -Use this workflow when modifying database schema for LCBP3-DMS. -Follows `specs/06-Decision-Records/ADR-009-database-strategy.md` — **NO TypeORM migrations**. - -## Pre-Change Checklist - -- [ ] Change is required by a spec in `specs/01-Requirements/` -- [ ] Existing data impact has been assessed -- [ ] No SQL triggers are being added (business logic in NestJS only) - -## Steps - -1. **Read current schema** — load the full schema file: - -``` -specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql -``` - -2. **Read data dictionary** — understand current field definitions: - -``` -specs/03-Data-and-Storage/03-01-data-dictionary.md -``` - -// turbo 3. **Identify impact scope** — determine which tables, columns, indexes, or constraints are affected. List: - -- Tables being modified/created -- Columns being added/renamed/dropped -- Foreign key relationships affected -- Indexes being added/modified -- Seed data impact (if any) - -4. **Modify schema SQL** — edit `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql`: - - Add/modify table definitions - - Maintain consistent formatting (uppercase SQL keywords, lowercase identifiers) - - Add inline comments for new columns explaining purpose - - Ensure `DEFAULT` values and `NOT NULL` constraints are correct - - Add `version` column with `@VersionColumn()` marker comment if optimistic locking is needed - -> [!CAUTION] -> **NEVER use SQL Triggers.** All business logic must live in NestJS services. - -5. **Update data dictionary** — edit `specs/03-Data-and-Storage/03-01-data-dictionary.md`: - - Add new tables/columns with descriptions - - Update data types and constraints - - Document business rules for new fields - - Add enum value definitions if applicable - -6. **Update seed data** (if applicable): - - `specs/03-Data-and-Storage/lcbp3-v1.7.0-seed-basic.sql` — for reference/lookup data - - `specs/03-Data-and-Storage/lcbp3-v1.7.0-seed-permissions.sql` — for new CASL permissions - -7. **Update TypeORM entity** — modify corresponding `backend/src/modules//entities/*.entity.ts`: - - Map ONLY columns defined in schema SQL - - Use correct TypeORM decorators (`@Column`, `@PrimaryGeneratedColumn`, `@ManyToOne`, etc.) - - Add `@VersionColumn()` if optimistic locking is needed - -8. **Update DTOs** — if new columns are exposed via API: - - Add fields to `create-*.dto.ts` and/or `update-*.dto.ts` - - Add `class-validator` decorators for all new fields - - Never use `any` type - -// turbo 9. **Run type check** — verify no TypeScript errors: - -```bash -cd backend && npx tsc --noEmit -``` - -10. **Generate SQL diff** — create a summary of changes for the user to apply manually: - -``` --- Schema Change Summary --- Date: --- Feature: --- Tables affected: --- --- ⚠️ Apply this SQL to the live database manually: - -ALTER TABLE ...; --- or -CREATE TABLE ...; -``` - -11. **Notify user** — present the SQL diff and remind them: - - Apply the SQL change to the live database manually - - Verify the change doesn't break existing data - - Run `pnpm test` after applying to confirm entity mappings work - -## Common Patterns - -| Change Type | Template | -| ----------- | -------------------------------------------------------------- | -| Add column | `ALTER TABLE \`table\` ADD COLUMN \`col\` TYPE DEFAULT value;` | -| Add table | Full `CREATE TABLE` with constraints and indexes | -| Add index | `CREATE INDEX \`idx_table_col\` ON \`table\` (\`col\`);` | -| Add FK | `ALTER TABLE \`child\` ADD CONSTRAINT ... FOREIGN KEY ...` | -| Add enum | Add to data dictionary + `ENUM('val1','val2')` in column def | - -## On Error - -- If schema SQL has syntax errors → fix and re-validate with `tsc --noEmit` -- If entity mapping doesn't match schema → compare column-by-column against SQL -- If seed data conflicts → check unique constraints and foreign keys diff --git a/.agent/rules/workflows/speckit.prepare.md b/.agent/rules/workflows/speckit.prepare.md deleted file mode 100644 index d7fb5f7..0000000 --- a/.agent/rules/workflows/speckit.prepare.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Execute the full preparation pipeline (Specify -> Clarify -> Plan -> Tasks -> Analyze) in sequence. ---- - -# Workflow: speckit.prepare - -This workflow orchestrates the sequential execution of the Speckit preparation phase skills (02-06). - -1. **Step 1: Specify (Skill 02)** - - Goal: Create or update the `spec.md` based on user input. - - Action: Read and execute `.agents/skills/speckit.specify/SKILL.md`. - -2. **Step 2: Clarify (Skill 03)** - - Goal: Refine the `spec.md` by identifying and resolving ambiguities. - - Action: Read and execute `.agents/skills/speckit.clarify/SKILL.md`. - -3. **Step 3: Plan (Skill 04)** - - Goal: Generate `plan.md` from the finalized spec. - - Action: Read and execute `.agents/skills/speckit.plan/SKILL.md`. - -4. **Step 4: Tasks (Skill 05)** - - Goal: Generate actionable `tasks.md` from the plan. - - Action: Read and execute `.agents/skills/speckit.tasks/SKILL.md`. - -5. **Step 5: Analyze (Skill 06)** - - Goal: Validate consistency across all design artifacts (spec, plan, tasks). - - Action: Read and execute `.agents/skills/speckit.analyze/SKILL.md`. diff --git a/.agent/rules/workflows/util-speckit.checklist.md b/.agent/rules/workflows/util-speckit.checklist.md deleted file mode 100644 index 49aa2d9..0000000 --- a/.agent/rules/workflows/util-speckit.checklist.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Generate a custom checklist for the current feature based on user requirements. ---- - -# Workflow: speckit.checklist - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.checklist/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit.specify` first to create the feature specification diff --git a/.agent/rules/workflows/util-speckit.diff.md b/.agent/rules/workflows/util-speckit.diff.md deleted file mode 100644 index da3dd20..0000000 --- a/.agent/rules/workflows/util-speckit.diff.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Compare two versions of a spec or plan to highlight changes. ---- - -# Workflow: speckit.diff - -1. **Context Analysis**: - - The user has provided an input prompt (optional file paths or version references). - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.diff/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no files to compare: Use current feature's `spec.md` vs git HEAD - - If `spec.md` doesn't exist: Run `/speckit.specify` first diff --git a/.agent/rules/workflows/util-speckit.migrate.md b/.agent/rules/workflows/util-speckit.migrate.md deleted file mode 100644 index cd2e5b4..0000000 --- a/.agent/rules/workflows/util-speckit.migrate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Migrate existing projects into the speckit structure by generating spec.md, plan.md, and tasks.md from existing code. ---- - -# Workflow: speckit.migrate - -1. **Context Analysis**: - - The user has provided an input prompt (path to analyze, feature name). - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.migrate/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If path doesn't exist: Ask user to provide valid directory path - - If no code found: Report that no analyzable code was detected diff --git a/.agent/rules/workflows/util-speckit.quizme.md b/.agent/rules/workflows/util-speckit.quizme.md deleted file mode 100644 index 11f70af..0000000 --- a/.agent/rules/workflows/util-speckit.quizme.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Challenge the specification with Socratic questioning to identify logical gaps, unhandled edge cases, and robustness issues. ---- - -// turbo-all - -# Workflow: speckit.quizme - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.quizme/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If required files don't exist, inform the user which prerequisite workflow to run first (e.g., `/speckit.specify` to create `spec.md`). diff --git a/.agent/rules/workflows/util-speckit.status.md b/.agent/rules/workflows/util-speckit.status.md deleted file mode 100644 index b2f5089..0000000 --- a/.agent/rules/workflows/util-speckit.status.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Display a dashboard showing feature status, completion percentage, and blockers. ---- - -// turbo-all - -# Workflow: speckit.status - -1. **Context Analysis**: - - The user may optionally specify a feature to focus on. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.status/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no features exist: Report "No features found. Run `/speckit.specify` to create your first feature." diff --git a/.agent/rules/workflows/util-speckit.taskstoissues.md b/.agent/rules/workflows/util-speckit.taskstoissues.md deleted file mode 100644 index 0cdac6e..0000000 --- a/.agent/rules/workflows/util-speckit.taskstoissues.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. ---- - -# Workflow: speckit.taskstoissues - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.taskstoissues/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit.tasks` first diff --git a/.agents/README.md b/.agents/README.md index 73d343d..c2ef967 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -2,7 +2,7 @@ > **The Event Horizon of Software Quality.** > _Adapted for Google Antigravity IDE from [github/spec-kit](https://github.com/github/spec-kit)._ -> _Version: 1.2.0 — LCBP3-DMS Edition (v1.8.1 UAT Ready)_ +> _Version: 1.8.6 — LCBP3-DMS Edition (v1.8.6 Production Ready)_ --- @@ -55,7 +55,7 @@ Some skills and scripts reference a `.specify/` directory for templates and proj The toolkit is organized into modular components that provide both the logic (Scripts) and the structure (Templates) for the agent. ```text -.agents/ +.agents/ # Agent Skills & Rules ├── skills/ # @ Mentions (Agent Intelligence) │ ├── nestjs-best-practices/ # NestJS Architecture Patterns │ ├── next-best-practices/ # Next.js App Router Patterns @@ -78,32 +78,37 @@ The toolkit is organized into modular components that provide both the logic (Sc │ ├── speckit-tester/ # Test Runner & Coverage │ └── speckit-validate/ # Implementation Validator │ -├── workflows/ # / Slash Commands (Orchestration) -│ ├── 00-speckit-all.md # Full Pipeline (10 steps: Specify → Validate) -│ ├── 01–11-speckit-*.md # Individual phase workflows -│ ├── speckit-prepare.md # Prep Pipeline (5 steps: Specify → Analyze) -│ ├── schema-change.md # DB Schema Change (ADR-009) -│ ├── create-backend-module.md # NestJS Module Scaffolding -│ ├── create-frontend-page.md # Next.js Page Scaffolding -│ ├── deploy.md # Deployment via Gitea CI/CD -│ └── util-speckit-*.md # Utilities (checklist, diff, migrate, etc.) +├── rules/ # Project Context & Validation Rules +│ ├── 00-project-context.md # Role, Persona, Rule Tiers +│ ├── 01-adr-019-uuid.md # UUID Strategy (Critical) +│ ├── 02-security.md # Security Requirements +│ ├── 03-typescript.md # TypeScript Standards +│ ├── 04-domain-terminology.md # DMS Glossary Compliance +│ ├── 05-forbidden-actions.md # Critical Prohibited Patterns +│ ├── 06-backend-patterns.md # NestJS Architecture Rules +│ ├── 07-frontend-patterns.md # Next.js App Router Rules +│ ├── 08-development-flow.md # Development Workflow +│ ├── 09-commit-checklist.md # Pre-commit Validation +│ ├── 10-error-handling.md # ADR-007 Compliance +│ └── 11-ai-integration.md # ADR-018/020 AI Boundaries │ └── scripts/ ├── bash/ # Bash Core (Kinetic logic) - │ ├── common.sh # Shared utilities & path resolution - │ ├── check-prerequisites.sh # Prerequisite validation - │ ├── create-new-feature.sh # Feature branch creation - │ ├── setup-plan.sh # Plan template setup - │ ├── update-agent-context.sh # Agent file updater (main) - │ ├── plan-parser.sh # Plan data extraction (module) - │ ├── content-generator.sh # Language-specific templates (module) - │ └── agent-registry.sh # 17-agent type registry (module) ├── powershell/ # PowerShell Equivalents (Windows-native) - │ ├── common.ps1 # Shared utilities & prerequisites - │ └── create-new-feature.ps1 # Feature branch creation ├── fix_links.py # Spec link fixer ├── verify_links.py # Spec link verifier └── start-mcp.js # MCP server launcher + +.windsurf/workflows/ # / Slash Commands (Orchestration) +├── 00-speckit.all.md # Full Pipeline (10 steps: Specify → Validate) +├── 01–11-speckit-*.md # Individual phase workflows +├── speckit-prepare.md # Prep Pipeline (5 steps: Specify → Analyze) +├── schema-change.md # DB Schema Change (ADR-009) +├── create-backend-module.md # NestJS Module Scaffolding +├── create-frontend-page.md # Next.js Page Scaffolding +├── deploy.md # Deployment via Gitea CI/CD +├── review.md # Code Review Workflow +└── util-speckit-*.md # Utilities (checklist, diff, migrate, etc.) ``` --- @@ -254,19 +259,19 @@ If you change your mind mid-project: --- -## 🏗️ LCBP3-DMS Project Notes (v1.8.1) +## 🏗️ LCBP3-DMS Project Notes (v1.8.6) -### 📊 Current Status: UAT Ready (2026-03-11) +### 📊 Current Status: Production Ready (2026-04-14) -| Area | Status | -| ------------- | ------------------------------------- | -| Backend | ✅ 18 Modules, Production Ready | -| Frontend | ✅ 100% Complete | -| Database | ✅ Schema v1.8.0 Stable | -| Documentation | ✅ **10/10 Gaps Closed** | -| AI Migration | 🔄 Pre-migration Setup (n8n + Ollama) | -| UAT | 🔄 In Progress | -| Deployment | 📋 Pending Go-Live | +| Area | Status | +| ------------- | ------------------------------- | +| Backend | ✅ 18 Modules, Production Ready | +| Frontend | ✅ 100% Complete | +| Database | ✅ Schema v1.8.6 Stable | +| Documentation | ✅ **10/10 Gaps Closed** | +| AI Migration | ✅ Ollama Integration Complete | +| UAT | ✅ Completed Successfully | +| Deployment | ✅ Production Deployed | ### 📁 Key Spec Files (Always Check Before Writing Code) @@ -300,4 +305,151 @@ If you change your mind mid-project: --- +## 🔧 Troubleshooting + +### Common Issues & Solutions + +#### **Version Inconsistency Errors** + +**Problem**: Scripts report version mismatches between files. + +**Solution**: + +```bash +# Run version validation +./scripts/bash/validate-versions.sh + +# Fix by updating all files to v1.8.6 +# Then re-run validation to confirm +``` + +**Files to check**: + +- `.agents/README.md` +- `.agents/skills/VERSION` +- `.agents/rules/00-project-context.md` +- `.agents/skills/skills.md` + +#### **Missing Workflow Files** + +**Problem**: Workflows not found in `.windsurf/workflows/`. + +**Solution**: + +```bash +# Sync workflow check +./scripts/bash/sync-workflows.sh + +# Verify all 23 expected workflows are present +# Create missing ones from templates if needed +``` + +#### **Skill Health Issues** + +**Problem**: Skills missing SKILL.md or required sections. + +**Solution**: + +```bash +# Run comprehensive skill audit +./scripts/bash/audit-skills.sh + +# Check specific skill issues +# Missing files will be listed with specific errors +``` + +**Required SKILL.md sections**: + +- Front matter: `name`, `description`, `version` +- Content: `## Role`, `## Task` + +#### **Script Permission Issues** + +**Problem**: Bash scripts not executable. + +**Solution**: + +```bash +# Make scripts executable +chmod +x .agents/scripts/bash/*.sh + +# Verify with +ls -la .agents/scripts/bash/ +``` + +#### **PowerShell Execution Policy** + +**Problem**: PowerShell scripts blocked by execution policy. + +**Solution**: + +```powershell +# Check current policy +Get-ExecutionPolicy + +# Allow scripts for current user +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +# Or run bypass for single script +PowerShell -ExecutionPolicy Bypass -File .agents/scripts/powershell/audit-skills.ps1 +``` + +### Debug Mode + +**Enable verbose output**: + +```bash +# Run scripts with debug info +bash -x .agents/scripts/bash/audit-skills.sh + +# PowerShell with verbose output +$VerbosePreference = "Continue" +. .agents/scripts/powershell/audit-skills.ps1 +``` + +### Health Check Commands + +**Quick health assessment**: + +```bash +# 1. Check versions +./scripts/bash/validate-versions.sh + +# 2. Audit skills +./scripts/bash/audit-skills.sh + +# 3. Sync workflows +./scripts/bash/sync-workflows.sh + +# 4. Check directory structure +find .agents -type f -name "*.md" | wc -l +find .windsurf/workflows -name "*.md" | wc -l +``` + +**PowerShell equivalent**: + +```powershell +# 1. Check versions +. .agents/scripts/powershell/validate-versions.ps1 + +# 2. Audit skills +. .agents/scripts/powershell/audit-skills.ps1 + +# 3. Count files +(Get-ChildItem -Path .agents -Recurse -Filter "*.md").Count +(Get-ChildItem -Path .windsurf/workflows -Filter "*.md").Count +``` + +### Getting Help + +**If issues persist**: + +1. Check LCBP3 project version alignment +2. Verify `.specify/` directory structure (if using templates) +3. Ensure all dependencies are installed (bash, powershell core) +4. Review the specific error messages in script output +5. Check this README for workflow path updates (`.windsurf/workflows`) + +--- + _Built with logic from [Spec-Kit](https://github.com/github/spec-kit). Powered by Antigravity._ diff --git a/.agent/rules/00-project-context.md b/.agents/rules/00-project-context.md similarity index 98% rename from .agent/rules/00-project-context.md rename to .agents/rules/00-project-context.md index f639ba3..1951e20 100644 --- a/.agent/rules/00-project-context.md +++ b/.agents/rules/00-project-context.md @@ -24,7 +24,7 @@ Every response must be **precise**, **spec-compliant**, and **production-ready** ## Project Information - **Project:** NAP-DMS (LCBP3) -- **Version:** 1.8.5 +- **Version:** 1.8.6 - **Stack:** NestJS + Next.js + TypeScript + MariaDB + Ollama (AI) - **Repo:** https://git.np-dms.work/np-dms/lcbp3 diff --git a/.agent/rules/01-adr-019-uuid.md b/.agents/rules/01-adr-019-uuid.md similarity index 100% rename from .agent/rules/01-adr-019-uuid.md rename to .agents/rules/01-adr-019-uuid.md diff --git a/.agent/rules/02-security.md b/.agents/rules/02-security.md similarity index 100% rename from .agent/rules/02-security.md rename to .agents/rules/02-security.md diff --git a/.agent/rules/03-typescript.md b/.agents/rules/03-typescript.md similarity index 100% rename from .agent/rules/03-typescript.md rename to .agents/rules/03-typescript.md diff --git a/.agent/rules/04-domain-terminology.md b/.agents/rules/04-domain-terminology.md similarity index 100% rename from .agent/rules/04-domain-terminology.md rename to .agents/rules/04-domain-terminology.md diff --git a/.agent/rules/05-forbidden-actions.md b/.agents/rules/05-forbidden-actions.md similarity index 100% rename from .agent/rules/05-forbidden-actions.md rename to .agents/rules/05-forbidden-actions.md diff --git a/.windsurf/rules/06-backend-patterns.md b/.agents/rules/06-backend-patterns.md similarity index 100% rename from .windsurf/rules/06-backend-patterns.md rename to .agents/rules/06-backend-patterns.md diff --git a/.windsurf/rules/07-frontend-patterns.md b/.agents/rules/07-frontend-patterns.md similarity index 100% rename from .windsurf/rules/07-frontend-patterns.md rename to .agents/rules/07-frontend-patterns.md diff --git a/.agent/rules/08-development-flow.md b/.agents/rules/08-development-flow.md similarity index 100% rename from .agent/rules/08-development-flow.md rename to .agents/rules/08-development-flow.md diff --git a/.agent/rules/09-commit-checklist.md b/.agents/rules/09-commit-checklist.md similarity index 100% rename from .agent/rules/09-commit-checklist.md rename to .agents/rules/09-commit-checklist.md diff --git a/.agent/rules/10-error-handling.md b/.agents/rules/10-error-handling.md similarity index 100% rename from .agent/rules/10-error-handling.md rename to .agents/rules/10-error-handling.md diff --git a/.agent/rules/11-ai-integration.md b/.agents/rules/11-ai-integration.md similarity index 100% rename from .agent/rules/11-ai-integration.md rename to .agents/rules/11-ai-integration.md diff --git a/.agents/scripts/advanced-validator.js b/.agents/scripts/advanced-validator.js new file mode 100644 index 0000000..7283bfe --- /dev/null +++ b/.agents/scripts/advanced-validator.js @@ -0,0 +1,571 @@ +#!/usr/bin/env node + +/** + * advanced-validator.js - Advanced validation capabilities for .agents + * Part of LCBP3-DMS Phase 3 enhancements + */ + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +// Configuration +const BASE_DIR = path.resolve(__dirname, '../..'); +const AGENTS_DIR = path.join(BASE_DIR, '.agents'); +const SKILLS_DIR = path.join(AGENTS_DIR, 'skills'); +const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows'); + +// Advanced validation class +class AdvancedValidator { + constructor() { + this.validationResults = { + timestamp: new Date().toISOString(), + validations: {}, + summary: { + total_validations: 0, + passed_validations: 0, + failed_validations: 0, + warnings: 0, + critical_issues: 0 + } + }; + this.criticalIssues = []; + } + + log(message, level = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + critical: '\x1b[35m', // Magenta + reset: '\x1b[0m' + }; + + const color = colors[level] || colors.info; + console.log(`${color}[${level.toUpperCase()}] ${message}${colors.reset}`); + } + + validateSkillFrontMatter(skillPath, skillName) { + const skillMdPath = path.join(skillPath, 'SKILL.md'); + + if (!fs.existsSync(skillMdPath)) { + this.addValidationResult(`skill_${skillName}_frontmatter`, 'fail', { + message: 'SKILL.md file not found', + path: skillMdPath + }); + return false; + } + + try { + const content = fs.readFileSync(skillMdPath, 'utf8'); + const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + + if (!frontMatterMatch) { + this.addValidationResult(`skill_${skillName}_frontmatter`, 'fail', { + message: 'No front matter found', + path: skillMdPath + }); + return false; + } + + try { + const frontMatter = yaml.load(frontMatterMatch[1]); + const requiredFields = ['name', 'description', 'version']; + const missingFields = requiredFields.filter(field => !frontMatter[field]); + + if (missingFields.length > 0) { + this.addValidationResult(`skill_${skillName}_frontmatter`, 'fail', { + message: `Missing required fields: ${missingFields.join(', ')}`, + missing_fields: missingFields, + front_matter: frontMatter, + path: skillMdPath + }); + return false; + } + + // Validate version format + const versionPattern = /^\d+\.\d+\.\d+$/; + if (!versionPattern.test(frontMatter.version)) { + this.addValidationResult(`skill_${skillName}_version_format`, 'warn', { + message: 'Version format should be X.Y.Z', + version: frontMatter.version, + path: skillMdPath + }); + } + + // Validate dependencies if present + if (frontMatter['depends-on']) { + const dependencies = Array.isArray(frontMatter['depends-on']) + ? frontMatter['depends-on'] + : [frontMatter['depends-on']]; + + for (const dep of dependencies) { + const depPath = path.join(SKILLS_DIR, dep); + if (!fs.existsSync(depPath)) { + this.addValidationResult(`skill_${skillName}_dependency_${dep}`, 'critical', { + message: `Dependency not found: ${dep}`, + dependency: dep, + path: skillMdPath + }); + } + } + } + + this.addValidationResult(`skill_${skillName}_frontmatter`, 'pass', { + message: 'Front matter is valid', + front_matter: frontMatter, + path: skillMdPath + }); + return true; + + } catch (yamlError) { + this.addValidationResult(`skill_${skillName}_frontmatter`, 'fail', { + message: `Invalid YAML in front matter: ${yamlError.message}`, + path: skillMdPath + }); + return false; + } + + } catch (error) { + this.addValidationResult(`skill_${skillName}_frontmatter`, 'fail', { + message: `Error reading SKILL.md: ${error.message}`, + path: skillMdPath + }); + return false; + } + } + + validateSkillContent(skillPath, skillName) { + const skillMdPath = path.join(skillPath, 'SKILL.md'); + + if (!fs.existsSync(skillMdPath)) { + return false; + } + + try { + const content = fs.readFileSync(skillMdPath, 'utf8'); + + // Check for required sections + const requiredSections = ['## Role', '## Task']; + const missingSections = requiredSections.filter(section => !content.includes(section)); + + if (missingSections.length > 0) { + this.addValidationResult(`skill_${skillName}_content`, 'fail', { + message: `Missing required sections: ${missingSections.join(', ')}`, + missing_sections: missingSections, + path: skillMdPath + }); + return false; + } + + // Check for forbidden patterns + const forbiddenPatterns = [ + { pattern: /TODO.*FIX/gi, message: 'TODO items should be resolved' }, + { pattern: /FIXME/gi, message: 'FIXME items should be addressed' }, + { pattern: /XXX/gi, message: 'XXX markers should be replaced' } + ]; + + for (const { pattern, message } of forbiddenPatterns) { + if (pattern.test(content)) { + this.addValidationResult(`skill_${skillName}_forbidden_patterns`, 'warn', { + message: `${message} found in content`, + pattern: pattern.toString(), + path: skillMdPath + }); + } + } + + // Validate content length + const contentLength = content.length; + if (contentLength < 500) { + this.addValidationResult(`skill_${skillName}_content_length`, 'warn', { + message: 'Skill content seems too short', + length: contentLength, + path: skillMdPath + }); + } + + this.addValidationResult(`skill_${skillName}_content`, 'pass', { + message: 'Skill content is valid', + length: contentLength, + path: skillMdPath + }); + return true; + + } catch (error) { + this.addValidationResult(`skill_${skillName}_content`, 'fail', { + message: `Error validating content: ${error.message}`, + path: skillMdPath + }); + return false; + } + } + + validateWorkflowStructure(workflowPath, workflowName) { + if (!fs.existsSync(workflowPath)) { + this.addValidationResult(`workflow_${workflowName}_exists`, 'fail', { + message: 'Workflow file not found', + path: workflowPath + }); + return false; + } + + try { + const content = fs.readFileSync(workflowPath, 'utf8'); + + // Check for markdown headers + if (!content.includes('#')) { + this.addValidationResult(`workflow_${workflowName}_structure`, 'fail', { + message: 'No markdown headers found', + path: workflowPath + }); + return false; + } + + // Check for workflow-specific patterns + const hasWorkflowContent = content.length > 200; + if (!hasWorkflowContent) { + this.addValidationResult(`workflow_${workflowName}_content`, 'warn', { + message: 'Workflow content seems too short', + length: content.length, + path: workflowPath + }); + } + + // Validate skill references + const skillReferences = content.match(/@speckit-\w+/g) || []; + for (const skillRef of skillReferences) { + const skillName = skillRef.replace('@', ''); + const skillPath = path.join(SKILLS_DIR, skillName); + + if (!fs.existsSync(skillPath)) { + this.addValidationResult(`workflow_${workflowName}_skill_ref_${skillName}`, 'critical', { + message: `Workflow references non-existent skill: ${skillRef}`, + skill_reference: skillRef, + path: workflowPath + }); + } + } + + this.addValidationResult(`workflow_${workflowName}_structure`, 'pass', { + message: 'Workflow structure is valid', + skill_references: skillReferences, + path: workflowPath + }); + return true; + + } catch (error) { + this.addValidationResult(`workflow_${workflowName}_structure`, 'fail', { + message: `Error validating workflow: ${error.message}`, + path: workflowPath + }); + return false; + } + } + + validateCrossReferences() { + this.log('Validating cross-references...', 'info'); + + // Check README.md references + const readmePath = path.join(AGENTS_DIR, 'README.md'); + if (fs.existsSync(readmePath)) { + const readmeContent = fs.readFileSync(readmePath, 'utf8'); + + // Check if README references correct workflow path + if (readmeContent.includes('.agents/workflows') && !readmeContent.includes('.windsurf/workflows')) { + this.addValidationResult('readme_workflow_reference', 'critical', { + message: 'README.md references .agents/workflows instead of .windsurf/workflows', + path: readmePath + }); + } + + // Check version consistency in README + const versionMatches = readmeContent.match(/v?(\d+\.\d+\.\d+)/g) || []; + const uniqueVersions = [...new Set(versionMatches)]; + + if (uniqueVersions.length > 1) { + this.addValidationResult('readme_version_consistency', 'warn', { + message: 'Multiple versions found in README.md', + versions: uniqueVersions, + path: readmePath + }); + } + } + + // Check skills.md references + const skillsMdPath = path.join(SKILLS_DIR, 'skills.md'); + if (fs.existsSync(skillsMdPath)) { + const skillsContent = fs.readFileSync(skillsMdPath, 'utf8'); + + // Validate skill dependency matrix + if (skillsContent.includes('## Skill Dependency Matrix')) { + this.addValidationResult('skills_dependency_matrix', 'pass', { + message: 'Skills documentation includes dependency matrix', + path: skillsMdPath + }); + } else { + this.addValidationResult('skills_dependency_matrix', 'warn', { + message: 'Skills documentation missing dependency matrix', + path: skillsMdPath + }); + } + } + } + + validateSecurityCompliance() { + this.log('Validating security compliance...', 'info'); + + // Check for security patterns in rules + const securityRulePath = path.join(AGENTS_DIR, 'rules', '02-security.md'); + if (fs.existsSync(securityRulePath)) { + const securityContent = fs.readFileSync(securityRulePath, 'utf8'); + + const requiredSecurityTopics = [ + 'authentication', + 'authorization', + 'rbac', + 'validation', + 'audit' + ]; + + const missingTopics = requiredSecurityTopics.filter(topic => + !securityContent.toLowerCase().includes(topic.toLowerCase()) + ); + + if (missingTopics.length > 0) { + this.addValidationResult('security_rules_completeness', 'warn', { + message: `Security rules missing topics: ${missingTopics.join(', ')}`, + missing_topics: missingTopics, + path: securityRulePath + }); + } else { + this.addValidationResult('security_rules_completeness', 'pass', { + message: 'Security rules cover all required topics', + path: securityRulePath + }); + } + } + + // Check for ADR-019 compliance in rules + const uuidRulePath = path.join(AGENTS_DIR, 'rules', '01-adr-019-uuid.md'); + if (fs.existsSync(uuidRulePath)) { + const uuidContent = fs.readFileSync(uuidRulePath, 'utf8'); + + const criticalUuidRules = [ + 'parseInt', + 'Number(', + 'publicId', + '@Exclude()' + ]; + + const missingRules = criticalUuidRules.filter(rule => + !uuidContent.includes(rule) + ); + + if (missingRules.length > 0) { + this.addValidationResult('uuid_rules_completeness', 'critical', { + message: `UUID rules missing critical patterns: ${missingRules.join(', ')}`, + missing_patterns: missingRules, + path: uuidRulePath + }); + } else { + this.addValidationResult('uuid_rules_completeness', 'pass', { + message: 'UUID rules cover all critical patterns', + path: uuidRulePath + }); + } + } + } + + validatePerformanceMetrics() { + this.log('Validating performance metrics...', 'info'); + + // Check file sizes + const criticalFiles = [ + { path: path.join(AGENTS_DIR, 'README.md'), name: 'README.md' }, + { path: path.join(SKILLS_DIR, 'skills.md'), name: 'skills.md' }, + { path: path.join(AGENTS_DIR, 'skills', 'VERSION'), name: 'VERSION' } + ]; + + for (const file of criticalFiles) { + if (fs.existsSync(file.path)) { + const stats = fs.statSync(file.path); + const sizeKB = stats.size / 1024; + + if (sizeKB > 100) { + this.addValidationResult(`file_size_${file.name}`, 'warn', { + message: `File ${file.name} is large (${sizeKB.toFixed(1)}KB)`, + size_kb: sizeKB, + path: file.path + }); + } else { + this.addValidationResult(`file_size_${file.name}`, 'pass', { + message: `File ${file.name} size is acceptable`, + size_kb: sizeKB, + path: file.path + }); + } + } + } + + // Check directory structure depth + function getDirectoryDepth(dirPath, currentDepth = 0) { + let maxDepth = currentDepth; + + if (fs.existsSync(dirPath)) { + const items = fs.readdirSync(dirPath); + for (const item of items) { + const itemPath = path.join(dirPath, item); + if (fs.statSync(itemPath).isDirectory()) { + const depth = getDirectoryDepth(itemPath, currentDepth + 1); + maxDepth = Math.max(maxDepth, depth); + } + } + } + + return maxDepth; + } + + const agentsDepth = getDirectoryDepth(AGENTS_DIR); + if (agentsDepth > 5) { + this.addValidationResult('directory_depth', 'warn', { + message: `.agents directory structure is deep (${agentsDepth} levels)`, + depth: agentsDepth, + path: AGENTS_DIR + }); + } else { + this.addValidationResult('directory_depth', 'pass', { + message: `.agents directory structure depth is acceptable`, + depth: agentsDepth, + path: AGENTS_DIR + }); + } + } + + addValidationResult(name, status, details) { + this.validationResults.validations[name] = { + status, + timestamp: new Date().toISOString(), + ...details + }; + + this.validationResults.summary.total_validations++; + + switch (status) { + case 'pass': + this.validationResults.summary.passed_validations++; + this.log(`${name}: PASS - ${details.message}`, 'pass'); + break; + case 'fail': + this.validationResults.summary.failed_validations++; + this.log(`${name}: FAIL - ${details.message}`, 'fail'); + break; + case 'warn': + this.validationResults.summary.warnings++; + this.log(`${name}: WARN - ${details.message}`, 'warn'); + break; + case 'critical': + this.validationResults.summary.critical_issues++; + this.criticalIssues.push({ name, ...details }); + this.log(`${name}: CRITICAL - ${details.message}`, 'critical'); + break; + } + } + + async runAdvancedValidation() { + this.log('Starting advanced validation...', 'info'); + this.log(`Base directory: ${BASE_DIR}`, 'info'); + + // Validate all skills + this.log('Validating skills...', 'info'); + if (fs.existsSync(SKILLS_DIR)) { + const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => { + const itemPath = path.join(SKILLS_DIR, item); + return fs.statSync(itemPath).isDirectory(); + }); + + for (const skillDir of skillDirs) { + const skillPath = path.join(SKILLS_DIR, skillDir); + this.validateSkillFrontMatter(skillPath, skillDir); + this.validateSkillContent(skillPath, skillDir); + } + } + + // Validate all workflows + this.log('Validating workflows...', 'info'); + if (fs.existsSync(WORKFLOWS_DIR)) { + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter(file => file.endsWith('.md')); + + for (const workflowFile of workflowFiles) { + const workflowPath = path.join(WORKFLOWS_DIR, workflowFile); + const workflowName = workflowFile.replace('.md', ''); + this.validateWorkflowStructure(workflowPath, workflowName); + } + } + + // Cross-reference validation + this.validateCrossReferences(); + + // Security compliance validation + this.validateSecurityCompliance(); + + // Performance metrics validation + this.validatePerformanceMetrics(); + + // Generate summary + this.generateSummary(); + + return this.validationResults; + } + + generateSummary() { + const { summary, critical_issues } = this.validationResults; + + this.log('=== Advanced Validation Summary ===', 'info'); + this.log(`Total validations: ${summary.total_validations}`, 'info'); + this.log(`Passed: ${summary.passed_validations}`, 'pass'); + this.log(`Failed: ${summary.failed_validations}`, summary.failed_validations > 0 ? 'fail' : 'info'); + this.log(`Warnings: ${summary.warnings}`, 'warn'); + this.log(`Critical issues: ${summary.critical_issues}`, 'critical'); + + if (critical_issues.length > 0) { + this.log('Critical Issues:', 'critical'); + critical_issues.forEach(issue => { + this.log(` - ${issue.name}: ${issue.message}`, 'critical'); + }); + } + + // Save validation results + const validationReportPath = path.join(AGENTS_DIR, 'reports', 'advanced-validation.json'); + const reportsDir = path.dirname(validationReportPath); + + if (!fs.existsSync(reportsDir)) { + fs.mkdirSync(reportsDir, { recursive: true }); + } + + fs.writeFileSync(validationReportPath, JSON.stringify(this.validationResults, null, 2)); + this.log(`Advanced validation report saved to: ${validationReportPath}`, 'info'); + } +} + +// CLI interface +async function main() { + const validator = new AdvancedValidator(); + + try { + const results = await validator.runAdvancedValidation(); + process.exit(results.summary.critical_issues > 0 ? 1 : 0); + } catch (error) { + console.error('Advanced validation failed:', error); + process.exit(1); + } +} + +// Export for use in other modules +module.exports = { AdvancedValidator }; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/.agents/scripts/bash/audit-skills.sh b/.agents/scripts/bash/audit-skills.sh new file mode 100644 index 0000000..187c51e --- /dev/null +++ b/.agents/scripts/bash/audit-skills.sh @@ -0,0 +1,188 @@ +#!/bin/bash + +# audit-skills.sh - Verify skill completeness and health +# Part of LCBP3-DMS Phase 2 improvements + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Base directory +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +AGENTS_DIR="$BASE_DIR/.agents" +SKILLS_DIR="$AGENTS_DIR/skills" + +echo "=== Skills Health Audit ===" +echo "Base directory: $BASE_DIR" +echo + +# Function to check if skill has required files +check_skill_health() { + local skill_dir="$1" + local skill_name="$(basename "$skill_dir")" + + local issues=0 + + # Check for SKILL.md + if [[ -f "$skill_dir/SKILL.md" ]]; then + echo -e "${GREEN} OK${NC}: $skill_name/SKILL.md" + else + echo -e "${RED} MISSING${NC}: $skill_name/SKILL.md" + ((issues++)) + fi + + # Check for templates directory (optional) + if [[ -d "$skill_dir/templates" ]]; then + template_count=$(find "$skill_dir/templates" -name "*.md" -type f | wc -l) + if [[ $template_count -gt 0 ]]; then + echo -e "${GREEN} OK${NC}: $skill_name/templates ($template_count files)" + else + echo -e "${YELLOW} EMPTY${NC}: $skill_name/templates (no files)" + fi + fi + + # Check SKILL.md content if exists + local skill_file="$skill_dir/SKILL.md" + if [[ -f "$skill_file" ]]; then + # Check for required front matter fields + local required_fields=("name" "description" "version") + for field in "${required_fields[@]}"; do + if grep -q "^$field:" "$skill_file"; then + echo -e " ${GREEN} FIELD${NC}: $field" + else + echo -e " ${RED} MISSING FIELD${NC}: $field" + ((issues++)) + fi + done + + # Check for Role section + if grep -q "^## Role$" "$skill_file"; then + echo -e " ${GREEN} SECTION${NC}: Role" + else + echo -e " ${YELLOW} MISSING SECTION${NC}: Role" + ((issues++)) + fi + + # Check for Task section + if grep -q "^## Task$" "$skill_file"; then + echo -e " ${GREEN} SECTION${NC}: Task" + else + echo -e " ${YELLOW} MISSING SECTION${NC}: Task" + ((issues++)) + fi + fi + + return $issues +} + +# Function to get skill version from SKILL.md +get_skill_version() { + local skill_file="$1" + if [[ -f "$skill_file" ]]; then + grep "^version:" "$skill_file" | head -1 | sed 's/version: *//' || echo "unknown" + else + echo "no_file" + fi +} + +# Check skills directory +if [[ ! -d "$SKILLS_DIR" ]]; then + echo -e "${RED}ERROR: Skills directory not found${NC}" + exit 1 +fi + +echo "Scanning skills directory: $SKILLS_DIR" +echo + +# Get all skill directories +SKILL_DIRS=() +while IFS= read -r -d '' dir; do + SKILL_DIRS+=("$dir") +done < <(find "$SKILLS_DIR" -maxdepth 1 -type d -not -path "$SKILLS_DIR" -print0 | sort -z) + +echo "Found ${#SKILL_DIRS[@]} skill directories" +echo + +# Audit each skill +TOTAL_ISSUES=0 +SKILL_SUMMARY=() + +for skill_dir in "${SKILL_DIRS[@]}"; do + skill_name="$(basename "$skill_dir")" + echo "Auditing: $skill_name" + echo "------------------------" + + check_skill_health "$skill_dir" + issues=$? + + skill_version=$(get_skill_version "$skill_dir/SKILL.md") + SKILL_SUMMARY+=("$skill_name:$issues:$skill_version") + + TOTAL_ISSUES=$((TOTAL_ISSUES + issues)) + echo +done + +# Summary report +echo "=== Skills Audit Summary ===" +echo + +echo "Skill Status:" +echo "-----------" +for summary in "${SKILL_SUMMARY[@]}"; do + IFS=':' read -r name issues version <<< "$summary" + if [[ $issues -eq 0 ]]; then + echo -e "${GREEN} HEALTHY${NC}: $name (v$version)" + else + echo -e "${RED} ISSUES${NC}: $name (v$version) - $issues issues" + fi +done + +echo + +# Check skills.md version consistency +SKILLS_VERSION_FILE="$SKILLS_DIR/VERSION" +if [[ -f "$SKILLS_VERSION_FILE" ]]; then + global_version=$(grep "^version:" "$SKILLS_VERSION_FILE" | sed 's/version: *//') + echo "Global skills version: v$global_version" + echo + + # Check for version mismatches + echo "Version Consistency Check:" + echo "------------------------" + VERSION_MISMATCHES=0 + + for summary in "${SKILL_SUMMARY[@]}"; do + IFS=':' read -r name issues version <<< "$summary" + if [[ "$version" != "unknown" && "$version" != "no_file" && "$version" != "$global_version" ]]; then + echo -e "${YELLOW} MISMATCH${NC}: $name is v$version, global is v$global_version" + ((VERSION_MISMATCHES++)) + fi + done + + if [[ $VERSION_MISMATCHES -eq 0 ]]; then + echo -e "${GREEN} All skills match global version${NC}" + fi +fi + +echo + +# Overall health +if [[ $TOTAL_ISSUES -eq 0 ]]; then + echo -e "${GREEN}=== SUCCESS: All skills healthy ===${NC}" + echo "Total skills: ${#SKILL_DIRS[@]}" + exit 0 +else + echo -e "${RED}=== ISSUES FOUND: $TOTAL_ISSUES total issues ===${NC}" + echo + echo "Recommendations:" + echo "1. Fix missing SKILL.md files" + echo "2. Add required front matter fields" + echo "3. Ensure Role and Task sections exist" + echo "4. Align skill versions with global version" + exit 1 +fi diff --git a/.agents/scripts/bash/sync-workflows.sh b/.agents/scripts/bash/sync-workflows.sh new file mode 100644 index 0000000..39576e4 --- /dev/null +++ b/.agents/scripts/bash/sync-workflows.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +# sync-workflows.sh - Sync workflow references between .agents and .windsurf +# Part of LCBP3-DMS Phase 2 improvements + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Base directory +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +AGENTS_DIR="$BASE_DIR/.agents" +WINDSURF_DIR="$BASE_DIR/.windsurf" +WORKFLOWS_DIR="$WINDSURF_DIR/workflows" + +echo "=== Workflow Synchronization Check ===" +echo "Base directory: $BASE_DIR" +echo + +# Function to check if workflow exists +check_workflow() { + local workflow_name="$1" + local workflow_file="$WORKFLOWS_DIR/$workflow_name" + + if [[ -f "$workflow_file" ]]; then + echo -e "${GREEN} EXISTS${NC}: $workflow_name" + return 0 + else + echo -e "${RED} MISSING${NC}: $workflow_name" + return 1 + fi +} + +# Function to list all workflows +list_workflows() { + if [[ -d "$WORKFLOWS_DIR" ]]; then + find "$WORKFLOWS_DIR" -name "*.md" -type f | sort + else + echo "No workflows directory found" + fi +} + +# Check directories +echo "Checking directory structure..." +if [[ -d "$AGENTS_DIR" ]]; then + echo -e "${GREEN} OK${NC}: .agents directory exists" +else + echo -e "${RED} ERROR${NC}: .agents directory not found" + exit 1 +fi + +if [[ -d "$WINDSURF_DIR" ]]; then + echo -e "${GREEN} OK${NC}: .windsurf directory exists" +else + echo -e "${RED} ERROR${NC}: .windsurf directory not found" + exit 1 +fi + +if [[ -d "$WORKFLOWS_DIR" ]]; then + echo -e "${GREEN} OK${NC}: workflows directory exists" +else + echo -e "${RED} ERROR${NC}: workflows directory not found" + exit 1 +fi + +echo + +# Expected workflows based on README documentation +echo "Checking expected workflows..." +EXPECTED_WORKFLOWS=( + "00-speckit.all.md" + "01-speckit.constitution.md" + "02-speckit.specify.md" + "03-speckit.clarify.md" + "04-speckit.plan.md" + "05-speckit.tasks.md" + "06-speckit.analyze.md" + "07-speckit.implement.md" + "08-speckit.checker.md" + "09-speckit.tester.md" + "10-speckit.reviewer.md" + "11-speckit.validate.md" + "speckit.prepare.md" + "schema-change.md" + "create-backend-module.md" + "create-frontend-page.md" + "deploy.md" + "review.md" + "util-speckit.checklist.md" + "util-speckit.diff.md" + "util-speckit.migrate.md" + "util-speckit.quizme.md" + "util-speckit.status.md" + "util-speckit.taskstoissues.md" +) + +MISSING_WORKFLOWS=0 + +for workflow in "${EXPECTED_WORKFLOWS[@]}"; do + if ! check_workflow "$workflow"; then + ((MISSING_WORKFLOWS++)) + fi +done + +echo + +# List all actual workflows +echo "All workflows in $WORKFLOWS_DIR:" +echo "--------------------------------" +while IFS= read -r workflow; do + echo " $(basename "$workflow")" +done < <(list_workflows) + +echo + +# Check for orphaned workflows (unexpected ones) +echo "Checking for unexpected workflows..." +ACTUAL_WORKFLOWS=() +while IFS= read -r workflow; do + ACTUAL_WORKFLOWS+=("$(basename "$workflow")") +done < <(list_workflows) + +for actual_workflow in "${ACTUAL_WORKFLOWS[@]}"; do + if [[ ! " ${EXPECTED_WORKFLOWS[*]} " =~ " ${actual_workflow} " ]]; then + echo -e "${YELLOW} UNEXPECTED${NC}: $actual_workflow" + fi +done + +echo + +# Summary +if [[ $MISSING_WORKFLOWS -eq 0 ]]; then + echo -e "${GREEN}=== SUCCESS: All expected workflows present ===${NC}" + echo "Total workflows: ${#ACTUAL_WORKFLOWS[@]}" + exit 0 +else + echo -e "${RED}=== FAILED: $MISSING_WORKFLOWS workflows missing ===${NC}" + echo + echo "To fix missing workflows:" + echo "1. Create missing workflow files in $WORKFLOWS_DIR" + echo "2. Use existing workflows as templates" + echo "3. Run this script again to verify" + exit 1 +fi diff --git a/.agents/scripts/bash/validate-versions.sh b/.agents/scripts/bash/validate-versions.sh new file mode 100644 index 0000000..9dc316a --- /dev/null +++ b/.agents/scripts/bash/validate-versions.sh @@ -0,0 +1,108 @@ +#!/bin/bash + +# validate-versions.sh - Check version consistency across .agents files +# Part of LCBP3-DMS Phase 2 improvements + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Base directory +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +AGENTS_DIR="$BASE_DIR/.agents" + +# Expected version (should match LCBP3 version) +EXPECTED_VERSION="1.8.6" + +echo "=== .agents Version Validation ===" +echo "Base directory: $BASE_DIR" +echo "Expected version: $EXPECTED_VERSION" +echo + +# Function to extract version from file +extract_version() { + local file="$1" + local pattern="$2" + + if [[ -f "$file" ]]; then + grep -o "$pattern" "$file" | head -1 | sed 's/.*\([0-9]\+\.[0-9]\+\.[0-9]\+\).*/\1/' || echo "NOT_FOUND" + else + echo "FILE_NOT_FOUND" + fi +} + +# Files to check +declare -A FILES_TO_CHECK=( + ["$AGENTS_DIR/README.md"]="Version: \([0-9]\+\.[0-9]\+\.[0-9]\+\)" + ["$AGENTS_DIR/skills/VERSION"]="version: \([0-9]\+\.[0-9]\+\.[0-9]\+\)" + ["$AGENTS_DIR/rules/00-project-context.md"]="Version: \([0-9]\+\.[0-9]\+\.[0-9]\+\)" + ["$AGENTS_DIR/skills/skills.md"]="V\([0-9]\+\.[0-9]\+\.[0-9]\+\)" +) + +# Track issues +ISSUES=0 + +echo "Checking version consistency..." +echo + +for file in "${!FILES_TO_CHECK[@]}"; do + pattern="${FILES_TO_CHECK[$file]}" + relative_path="${file#$BASE_DIR/}" + + version=$(extract_version "$file" "$pattern") + + if [[ "$version" == "NOT_FOUND" ]] || [[ "$version" == "FILE_NOT_FOUND" ]]; then + echo -e "${RED} ERROR${NC}: $relative_path - Version not found" + ((ISSUES++)) + elif [[ "$version" != "$EXPECTED_VERSION" ]]; then + echo -e "${RED} ERROR${NC}: $relative_path - Found v$version, expected v$EXPECTED_VERSION" + ((ISSUES++)) + else + echo -e "${GREEN} OK${NC}: $relative_path - v$version" + fi +done + +echo + +# Check for version mismatches in skill files +echo "Checking skill file versions..." +SKILL_VERSIONS_FILE="$AGENTS_DIR/skills/VERSION" +if [[ -f "$SKILL_VERSIONS_FILE" ]]; then + skills_version=$(extract_version "$SKILL_VERSIONS_FILE" "version: \([0-9]\+\.[0-9]\+\.[0-9]\+\)") + echo "Skills version file: v$skills_version" +fi + +# Check workflow versions (in .windsurf/workflows) +WORKFLOWS_DIR="$BASE_DIR/.windsurf/workflows" +if [[ -d "$WORKFLOWS_DIR" ]]; then + echo "Checking workflow files..." + workflow_count=0 + for workflow in "$WORKFLOWS_DIR"/*.md; do + if [[ -f "$workflow" ]]; then + workflow_count=$((workflow_count + 1)) + fi + done + echo -e "${GREEN} OK${NC}: Found $workflow_count workflow files" +else + echo -e "${YELLOW} WARNING${NC}: Workflows directory not found at $WORKFLOWS_DIR" +fi + +echo + +# Summary +if [[ $ISSUES -eq 0 ]]; then + echo -e "${GREEN}=== SUCCESS: All versions consistent ===${NC}" + exit 0 +else + echo -e "${RED}=== FAILED: $ISSUES version issues found ===${NC}" + echo + echo "To fix version issues:" + echo "1. Update files to use v$EXPECTED_VERSION" + echo "2. Ensure LCBP3 project version matches" + echo "3. Run this script again to verify" + exit 1 +fi diff --git a/.agents/scripts/ci-hooks.ps1 b/.agents/scripts/ci-hooks.ps1 new file mode 100644 index 0000000..bbbe3b7 --- /dev/null +++ b/.agents/scripts/ci-hooks.ps1 @@ -0,0 +1,516 @@ +# ci-hooks.ps1 - Continuous integration hooks for .agents (PowerShell version) +# Part of LCBP3-DMS Phase 3 enhancements + +param( + [Parameter(Mandatory=$false)] + [ValidateSet("pre-commit", "pre-push", "ci-pipeline", "install-hooks", "help")] + [string]$Command = "help" +) + +# Configuration +$BaseDir = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$AgentsDir = Join-Path $BaseDir ".agents" +$CILogDir = Join-Path $AgentsDir "logs\ci" +$CIReportDir = Join-Path $AgentsDir "reports\ci" + +# Ensure directories exist +if (-not (Test-Path $CILogDir)) { New-Item -ItemType Directory -Path $CILogDir -Force | Out-Null } +if (-not (Test-Path $CIReportDir)) { New-Item -ItemType Directory -Path $CIReportDir -Force | Out-Null } + +# Colors for output +$Colors = @{ + Red = "`e[0;31m" + Green = "`e[0;32m" + Yellow = "`e[1;33m" + Blue = "`e[0;34m" + NoColor = "`e[0m" +} + +# Logging function +function Write-CILog { + param( + [string]$Level, + [string]$Message + ) + + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logFile = Join-Path $CILogDir "ci-$(Get-Date -Format 'yyyy-MM-dd').log" + "$timestamp [$Level] $Message" | Out-File -FilePath $logFile -Append + + # Console output with colors + switch ($Level) { + "INFO" { Write-Host $Message -ForegroundColor $Colors.Blue } + "PASS" { Write-Host $Message -ForegroundColor $Colors.Green } + "WARN" { Write-Host $Message -ForegroundColor $Colors.Yellow } + "FAIL" { Write-Host $Message -ForegroundColor $Colors.Red } + default { Write-Host $Message } + } +} + +# Pre-commit hook +function Invoke-PreCommitHook { + Write-CILog "INFO" "Running pre-commit validation..." + + $exitCode = 0 + + # 1. Run version validation + Write-CILog "INFO" "Checking version consistency..." + $versionScript = Join-Path $AgentsDir "scripts\powershell\validate-versions.ps1" + if (Test-Path $versionScript) { + try { + & $versionScript | Out-File -FilePath (Join-Path $CILogDir "pre-commit-versions.log") -Append + Write-CILog "PASS" "Version validation passed" + } catch { + Write-CILog "FAIL" "Version validation failed" + $exitCode = 1 + } + } else { + Write-CILog "WARN" "Version validation script not found" + } + + # 2. Run skill audit + Write-CILog "INFO" "Auditing skills..." + $auditScript = Join-Path $AgentsDir "scripts\powershell\audit-skills.ps1" + if (Test-Path $auditScript) { + try { + & $auditScript | Out-File -FilePath (Join-Path $CILogDir "pre-commit-skills.log") -Append + Write-CILog "PASS" "Skill audit passed" + } catch { + Write-CILog "FAIL" "Skill audit failed" + $exitCode = 1 + } + } else { + Write-CILog "WARN" "Skill audit script not found" + } + + # 3. Run integration tests (if Node.js available) + if (Get-Command node -ErrorAction SilentlyContinue) { + Write-CILog "INFO" "Running integration tests..." + $testScript = Join-Path $AgentsDir "tests\skill-integration.test.js" + if (Test-Path $testScript) { + try { + node $testScript | Out-File -FilePath (Join-Path $CILogDir "pre-commit-tests.log") -Append + Write-CILog "PASS" "Integration tests passed" + } catch { + Write-CILog "WARN" "Integration tests failed (non-blocking)" + } + } else { + Write-CILog "WARN" "Integration test script not found" + } + } else { + Write-CILog "WARN" "Node.js not available, skipping integration tests" + } + + # 4. Check for forbidden patterns + Write-CILog "INFO" "Checking for forbidden patterns..." + $forbiddenPatterns = @("TODO", "FIXME", "XXX", "HACK") + $foundForbidden = $false + + foreach ($pattern in $forbiddenPatterns) { + $skillsDir = Join-Path $AgentsDir "skills" + if (Test-Path $skillsDir) { + $matches = Select-String -Path $skillsDir\*.md -Pattern $pattern -Recurse + if ($matches) { + Write-CILog "WARN" "Found forbidden pattern: $pattern" + $foundForbidden = $true + } + } + } + + if (-not $foundForbidden) { + Write-CILog "PASS" "No forbidden patterns found" + } + + # Generate pre-commit report + $reportFile = Join-Path $CIReportDir "pre-commit-$(Get-Date -Format 'yyyyMMdd-HHmmss').json" + $report = @{ + timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz") + hook_type = "pre-commit" + exit_code = $exitCode + checks_performed = @( + "version_validation", + "skill_audit", + "integration_tests", + "forbidden_patterns" + ) + log_files = @( + "pre-commit-versions.log", + "pre-commit-skills.log", + "pre-commit-tests.log" + ) + } + $report | ConvertTo-Json -Depth 10 | Out-File -FilePath $reportFile + + Write-CILog "INFO" "Pre-commit report saved to: $reportFile" + + if ($exitCode -eq 0) { + Write-CILog "PASS" "Pre-commit validation completed successfully" + } else { + Write-CILog "FAIL" "Pre-commit validation failed" + } + + return $exitCode +} + +# Pre-push hook +function Invoke-PrePushHook { + Write-CILog "INFO" "Running pre-push validation..." + + $exitCode = 0 + + # 1. Full health check + Write-CILog "INFO" "Running full health check..." + if (Get-Command node -ErrorAction SilentlyContinue) { + $healthScript = Join-Path $AgentsDir "scripts\health-monitor.js" + if (Test-Path $healthScript) { + try { + node $healthScript | Out-File -FilePath (Join-Path $CILogDir "pre-push-health.log") -Append + Write-CILog "PASS" "Health check passed" + } catch { + Write-CILog "FAIL" "Health check failed" + $exitCode = 1 + } + } else { + Write-CILog "WARN" "Health monitor script not found" + } + } else { + Write-CILog "WARN" "Node.js not available, using basic health check" + $auditScript = Join-Path $AgentsDir "scripts\powershell\audit-skills.ps1" + if (Test-Path $auditScript) { + try { + & $auditScript | Out-File -FilePath (Join-Path $CILogDir "pre-push-basic.log") -Append + Write-CILog "PASS" "Basic health check passed" + } catch { + Write-CILog "FAIL" "Basic health check failed" + $exitCode = 1 + } + } + } + + # 2. Advanced validation (if available) + if (Get-Command node -ErrorAction SilentlyContinue) { + $advancedScript = Join-Path $AgentsDir "scripts\advanced-validator.js" + if (Test-Path $advancedScript) { + Write-CILog "INFO" "Running advanced validation..." + try { + node $advancedScript | Out-File -FilePath (Join-Path $CILogDir "pre-push-advanced.log") -Append + Write-CILog "PASS" "Advanced validation passed" + } catch { + Write-CILog "WARN" "Advanced validation found issues (non-blocking)" + } + } + } + + # 3. Dependency validation + if (Get-Command node -ErrorAction SilentlyContinue) { + $dependencyScript = Join-Path $AgentsDir "scripts\dependency-validator.js" + if (Test-Path $dependencyScript) { + Write-CILog "INFO" "Running dependency validation..." + try { + node $dependencyScript | Out-File -FilePath (Join-Path $CILogDir "pre-push-dependencies.log") -Append + Write-CILog "PASS" "Dependency validation passed" + } catch { + Write-CILog "WARN" "Dependency validation found issues (non-blocking)" + } + } + } + + # 4. Performance monitoring + if (Get-Command node -ErrorAction SilentlyContinue) { + $performanceScript = Join-Path $AgentsDir "scripts\performance-monitor.js" + if (Test-Path $performanceScript) { + Write-CILog "INFO" "Running performance monitoring..." + try { + node $performanceScript | Out-File -FilePath (Join-Path $CILogDir "pre-push-performance.log") -Append + Write-CILog "PASS" "Performance monitoring passed" + } catch { + Write-CILog "WARN" "Performance monitoring found issues (non-blocking)" + } + } + } + + # Generate pre-push report + $reportFile = Join-Path $CIReportDir "pre-push-$(Get-Date -Format 'yyyyMMdd-HHmmss').json" + $report = @{ + timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz") + hook_type = "pre-push" + exit_code = $exitCode + checks_performed = @( + "health_check", + "advanced_validation", + "dependency_validation", + "performance_monitoring" + ) + log_files = @( + "pre-push-health.log", + "pre-push-advanced.log", + "pre-push-dependencies.log", + "pre-push-performance.log" + ) + } + $report | ConvertTo-Json -Depth 10 | Out-File -FilePath $reportFile + + Write-CILog "INFO" "Pre-push report saved to: $reportFile" + + if ($exitCode -eq 0) { + Write-CILog "PASS" "Pre-push validation completed successfully" + } else { + Write-CILog "FAIL" "Pre-push validation failed" + } + + return $exitCode +} + +# CI pipeline hook +function Invoke-CIPipelineHook { + Write-CILog "INFO" "Running CI pipeline validation..." + + $exitCode = 0 + $pipelineStart = Get-Date + + # Create pipeline workspace + $workspace = Join-Path $CIReportDir "pipeline-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + New-Item -ItemType Directory -Path $workspace -Force | Out-Null + + # 1. Environment validation + Write-CILog "INFO" "Validating CI environment..." + + # Check required tools + $requiredTools = @("node", "npm") + foreach ($tool in $requiredTools) { + if (Get-Command $tool -ErrorAction SilentlyContinue) { + Write-CILog "PASS" "Tool available: $tool" + } else { + Write-CILog "FAIL" "Tool missing: $tool" + $exitCode = 1 + } + } + + # Check Node.js modules + $packageJson = Join-Path $AgentsDir "package.json" + if (Test-Path $packageJson) { + Push-Location $AgentsDir + try { + npm list --depth=0 | Out-Null + Write-CILog "PASS" "Node.js dependencies installed" + } catch { + Write-CILog "WARN" "Installing Node.js dependencies..." + npm install | Out-File -FilePath (Join-Path $workspace "npm-install.log") + if ($LASTEXITCODE -ne 0) { + Write-CILog "FAIL" "Failed to install Node.js dependencies" + $exitCode = 1 + } + } + Pop-Location + } + + # 2. Full test suite + Write-CILog "INFO" "Running full test suite..." + + # Integration tests + $integrationTest = Join-Path $AgentsDir "tests\skill-integration.test.js" + if (Test-Path $integrationTest) { + try { + node $integrationTest | Out-File -FilePath (Join-Path $workspace "integration-tests.log") + Write-CILog "PASS" "Integration tests passed" + } catch { + Write-CILog "FAIL" "Integration tests failed" + $exitCode = 1 + } + } + + # Workflow validation tests + $workflowTest = Join-Path $AgentsDir "tests\workflow-validation.test.js" + if (Test-Path $workflowTest) { + try { + node $workflowTest | Out-File -FilePath (Join-Path $workspace "workflow-tests.log") + Write-CILog "PASS" "Workflow validation tests passed" + } catch { + Write-CILog "FAIL" "Workflow validation tests failed" + $exitCode = 1 + } + } + + # 3. Comprehensive validation + Write-CILog "INFO" "Running comprehensive validation..." + + # Health monitoring + $healthScript = Join-Path $AgentsDir "scripts\health-monitor.js" + if (Test-Path $healthScript) { + try { + node $healthScript | Out-File -FilePath (Join-Path $workspace "health-check.log") + Write-CILog "PASS" "Health monitoring passed" + } catch { + Write-CILog "FAIL" "Health monitoring failed" + $exitCode = 1 + } + } + + # Advanced validation + $advancedScript = Join-Path $AgentsDir "scripts\advanced-validator.js" + if (Test-Path $advancedScript) { + try { + node $advancedScript | Out-File -FilePath (Join-Path $workspace "advanced-validation.log") + Write-CILog "PASS" "Advanced validation passed" + } catch { + Write-CILog "WARN" "Advanced validation found issues" + } + } + + # Dependency validation + $dependencyScript = Join-Path $AgentsDir "scripts\dependency-validator.js" + if (Test-Path $dependencyScript) { + try { + node $dependencyScript | Out-File -FilePath (Join-Path $workspace "dependency-validation.log") + Write-CILog "PASS" "Dependency validation passed" + } catch { + Write-CILog "WARN" "Dependency validation found issues" + } + } + + # Performance monitoring + $performanceScript = Join-Path $AgentsDir "scripts\performance-monitor.js" + if (Test-Path $performanceScript) { + try { + node $performanceScript | Out-File -FilePath (Join-Path $workspace "performance-monitor.log") + Write-CILog "PASS" "Performance monitoring passed" + } catch { + Write-CILog "WARN" "Performance monitoring found issues" + } + } + + # 4. Generate artifacts + Write-CILog "INFO" "Generating CI artifacts..." + + $pipelineEnd = Get-Date + $duration = ($pipelineEnd - $pipelineStart).TotalSeconds + + # Consolidated report + $reportFile = Join-Path $workspace "ci-pipeline-report.json" + $report = @{ + timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz") + pipeline_type = "full_ci" + duration_seconds = [int]$duration + exit_code = $exitCode + environment = @{ + node_version = (node --version) + platform = $env:OS + working_directory = $BaseDir + } + checks_performed = @( + "environment_validation", + "integration_tests", + "workflow_validation_tests", + "health_monitoring", + "advanced_validation", + "dependency_validation", + "performance_monitoring" + ) + artifacts = @( + "integration-tests.log", + "workflow-tests.log", + "health-check.log", + "advanced-validation.log", + "dependency-validation.log", + "performance-monitor.log", + "npm-install.log" + ) + workspace = $workspace + } + $report | ConvertTo-Json -Depth 10 | Out-File -FilePath $reportFile + + Write-CILog "INFO" "CI pipeline report saved to: $reportFile" + Write-CILog "INFO" "CI artifacts saved to: $workspace" + Write-CILog "INFO" "Pipeline duration: $([int]$duration)s" + + if ($exitCode -eq 0) { + Write-CILog "PASS" "CI pipeline completed successfully" + } else { + Write-CILog "FAIL" "CI pipeline failed" + } + + return $exitCode +} + +# Install Git hooks +function Install-GitHooks { + Write-CILog "INFO" "Installing Git hooks..." + + $hooksDir = Join-Path $BaseDir ".git\hooks" + $agentsHooksDir = Join-Path $AgentsDir "scripts\git-hooks" + + # Create git-hooks directory + if (-not (Test-Path $agentsHooksDir)) { + New-Item -ItemType Directory -Path $agentsHooksDir -Force | Out-Null + } + + # Create pre-commit hook + $preCommitContent = @' +#!/bin/bash +# Pre-commit hook for .agents validation +echo "Running .agents pre-commit validation..." +if bash .agents/scripts/ci-hooks.sh pre-commit; then + echo "Pre-commit validation passed" + exit 0 +else + echo "Pre-commit validation failed" + exit 1 +fi +'@ + $preCommitContent | Out-File -FilePath (Join-Path $agentsHooksDir "pre-commit") -Encoding UTF8 + + # Create pre-push hook + $prePushContent = @' +#!/bin/bash +# Pre-push hook for .agents validation +echo "Running .agents pre-push validation..." +if bash .agents/scripts/ci-hooks.sh pre-push; then + echo "Pre-push validation passed" + exit 0 +else + echo "Pre-push validation failed" + exit 1 +fi +'@ + $prePushContent | Out-File -FilePath (Join-Path $agentsHooksDir "pre-push") -Encoding UTF8 + + # Install hooks if .git directory exists + if (Test-Path $hooksDir) { + Copy-Item (Join-Path $agentsHooksDir "pre-commit") $hooksDir -Force + Copy-Item (Join-Path $agentsHooksDir "pre-push") $hooksDir -Force + Write-CILog "PASS" "Git hooks installed successfully" + } else { + Write-CILog "WARN" "Git repository not found, hooks copied to .agents\scripts\git-hooks" + } +} + +# Main execution +switch ($Command) { + "pre-commit" { + exit (Invoke-PreCommitHook) + } + "pre-push" { + exit (Invoke-PrePushHook) + } + "ci-pipeline" { + exit (Invoke-CIPipelineHook) + } + "install-hooks" { + Install-GitHooks + } + "help" { + Write-Host "Usage: .\ci-hooks.ps1 -Command {pre-commit|pre-push|ci-pipeline|install-hooks|help}" + Write-Host "" + Write-Host "Commands:" + Write-Host " pre-commit - Run pre-commit validation" + Write-Host " pre-push - Run pre-push validation" + Write-Host " ci-pipeline - Run full CI pipeline" + Write-Host " install-hooks - Install Git hooks" + Write-Host " help - Show this help" + } + default { + Write-Host "Unknown command: $Command" + Write-Host "Use 'help' to see available commands" + exit 1 + } +} diff --git a/.agents/scripts/ci-hooks.sh b/.agents/scripts/ci-hooks.sh new file mode 100644 index 0000000..0613676 --- /dev/null +++ b/.agents/scripts/ci-hooks.sh @@ -0,0 +1,445 @@ +#!/bin/bash + +# ci-hooks.sh - Continuous integration hooks for .agents +# Part of LCBP3-DMS Phase 3 enhancements + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Base directory +BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +AGENTS_DIR="$BASE_DIR/.agents" + +# CI configuration +CI_LOG_DIR="$AGENTS_DIR/logs/ci" +CI_REPORT_DIR="$AGENTS_DIR/reports/ci" + +# Ensure directories exist +mkdir -p "$CI_LOG_DIR" "$CI_REPORT_DIR" + +# Logging function +ci_log() { + local level="$1" + local message="$2" + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + local log_file="$CI_LOG_DIR/ci-$(date '+%Y-%m-%d').log" + + echo "[$timestamp] [$level] $message" | tee -a "$log_file" + + # Console output with colors + case "$level" in + "INFO") echo -e "${BLUE}$message${NC}" ;; + "PASS") echo -e "${GREEN}$message${NC}" ;; + "WARN") echo -e "${YELLOW}$message${NC}" ;; + "FAIL") echo -e "${RED}$message${NC}" ;; + *) echo "$message" ;; + esac +} + +# Pre-commit hook +pre_commit_hook() { + ci_log "INFO" "Running pre-commit validation..." + + local exit_code=0 + + # 1. Run version validation + ci_log "INFO" "Checking version consistency..." + if "$AGENTS_DIR/scripts/bash/validate-versions.sh" >> "$CI_LOG_DIR/pre-commit-versions.log" 2>&1; then + ci_log "PASS" "Version validation passed" + else + ci_log "FAIL" "Version validation failed" + exit_code=1 + fi + + # 2. Run skill audit + ci_log "INFO" "Auditing skills..." + if "$AGENTS_DIR/scripts/bash/audit-skills.sh" >> "$CI_LOG_DIR/pre-commit-skills.log" 2>&1; then + ci_log "PASS" "Skill audit passed" + else + ci_log "FAIL" "Skill audit failed" + exit_code=1 + fi + + # 3. Run integration tests (if Node.js available) + if command -v node >/dev/null 2>&1; then + ci_log "INFO" "Running integration tests..." + if node "$AGENTS_DIR/tests/skill-integration.test.js" >> "$CI_LOG_DIR/pre-commit-tests.log" 2>&1; then + ci_log "PASS" "Integration tests passed" + else + ci_log "WARN" "Integration tests failed (non-blocking)" + fi + else + ci_log "WARN" "Node.js not available, skipping integration tests" + fi + + # 4. Check for forbidden patterns + ci_log "INFO" "Checking for forbidden patterns..." + local forbidden_patterns=("TODO" "FIXME" "XXX" "HACK") + local found_forbidden=false + + for pattern in "${forbidden_patterns[@]}"; do + if grep -r "$pattern" "$AGENTS_DIR/skills" --include="*.md" >/dev/null 2>&1; then + ci_log "WARN" "Found forbidden pattern: $pattern" + found_forbidden=true + fi + done + + if [ "$found_forbidden" = false ]; then + ci_log "PASS" "No forbidden patterns found" + fi + + # Generate pre-commit report + local report_file="$CI_REPORT_DIR/pre-commit-$(date '+%Y%m%d-%H%M%S').json" + cat > "$report_file" << EOF +{ + "timestamp": "$(date -Iseconds)", + "hook_type": "pre-commit", + "exit_code": $exit_code, + "checks_performed": [ + "version_validation", + "skill_audit", + "integration_tests", + "forbidden_patterns" + ], + "log_files": [ + "pre-commit-versions.log", + "pre-commit-skills.log", + "pre-commit-tests.log" + ] +} +EOF + + ci_log "INFO" "Pre-commit report saved to: $report_file" + + if [ $exit_code -eq 0 ]; then + ci_log "PASS" "Pre-commit validation completed successfully" + else + ci_log "FAIL" "Pre-commit validation failed" + fi + + return $exit_code +} + +# Pre-push hook +pre_push_hook() { + ci_log "INFO" "Running pre-push validation..." + + local exit_code=0 + + # 1. Full health check + ci_log "INFO" "Running full health check..." + if command -v node >/dev/null 2>&1; then + if node "$AGENTS_DIR/scripts/health-monitor.js" >> "$CI_LOG_DIR/pre-push-health.log" 2>&1; then + ci_log "PASS" "Health check passed" + else + ci_log "FAIL" "Health check failed" + exit_code=1 + fi + else + ci_log "WARN" "Node.js not available, using basic health check" + if "$AGENTS_DIR/scripts/bash/audit-skills.sh" >> "$CI_LOG_DIR/pre-push-basic.log" 2>&1; then + ci_log "PASS" "Basic health check passed" + else + ci_log "FAIL" "Basic health check failed" + exit_code=1 + fi + fi + + # 2. Advanced validation (if available) + if command -v node >/dev/null 2>&1 && [ -f "$AGENTS_DIR/scripts/advanced-validator.js" ]; then + ci_log "INFO" "Running advanced validation..." + if node "$AGENTS_DIR/scripts/advanced-validator.js" >> "$CI_LOG_DIR/pre-push-advanced.log" 2>&1; then + ci_log "PASS" "Advanced validation passed" + else + ci_log "WARN" "Advanced validation found issues (non-blocking)" + fi + fi + + # 3. Dependency validation + if command -v node >/dev/null 2>&1 && [ -f "$AGENTS_DIR/scripts/dependency-validator.js" ]; then + ci_log "INFO" "Running dependency validation..." + if node "$AGENTS_DIR/scripts/dependency-validator.js" >> "$CI_LOG_DIR/pre-push-dependencies.log" 2>&1; then + ci_log "PASS" "Dependency validation passed" + else + ci_log "WARN" "Dependency validation found issues (non-blocking)" + fi + fi + + # 4. Performance monitoring + if command -v node >/dev/null 2>&1 && [ -f "$AGENTS_DIR/scripts/performance-monitor.js" ]; then + ci_log "INFO" "Running performance monitoring..." + if node "$AGENTS_DIR/scripts/performance-monitor.js" >> "$CI_LOG_DIR/pre-push-performance.log" 2>&1; then + ci_log "PASS" "Performance monitoring passed" + else + ci_log "WARN" "Performance monitoring found issues (non-blocking)" + fi + fi + + # Generate pre-push report + local report_file="$CI_REPORT_DIR/pre-push-$(date '+%Y%m%d-%H%M%S').json" + cat > "$report_file" << EOF +{ + "timestamp": "$(date -Iseconds)", + "hook_type": "pre-push", + "exit_code": $exit_code, + "checks_performed": [ + "health_check", + "advanced_validation", + "dependency_validation", + "performance_monitoring" + ], + "log_files": [ + "pre-push-health.log", + "pre-push-advanced.log", + "pre-push-dependencies.log", + "pre-push-performance.log" + ] +} +EOF + + ci_log "INFO" "Pre-push report saved to: $report_file" + + if [ $exit_code -eq 0 ]; then + ci_log "PASS" "Pre-push validation completed successfully" + else + ci_log "FAIL" "Pre-push validation failed" + fi + + return $exit_code +} + +# CI pipeline hook +ci_pipeline_hook() { + ci_log "INFO" "Running CI pipeline validation..." + + local exit_code=0 + local pipeline_start=$(date +%s) + + # Create pipeline workspace + local workspace="$CI_REPORT_DIR/pipeline-$(date '+%Y%m%d-%H%M%S')" + mkdir -p "$workspace" + + # 1. Environment validation + ci_log "INFO" "Validating CI environment..." + + # Check required tools + local required_tools=("node" "npm") + for tool in "${required_tools[@]}"; do + if command -v "$tool" >/dev/null 2>&1; then + ci_log "PASS" "Tool available: $tool" + else + ci_log "FAIL" "Tool missing: $tool" + exit_code=1 + fi + done + + # Check Node.js modules + if [ -f "$AGENTS_DIR/package.json" ]; then + cd "$AGENTS_DIR" + if npm list --depth=0 >/dev/null 2>&1; then + ci_log "PASS" "Node.js dependencies installed" + else + ci_log "WARN" "Installing Node.js dependencies..." + npm install >> "$workspace/npm-install.log" 2>&1 || { + ci_log "FAIL" "Failed to install Node.js dependencies" + exit_code=1 + } + fi + cd "$BASE_DIR" + fi + + # 2. Full test suite + ci_log "INFO" "Running full test suite..." + + # Integration tests + if node "$AGENTS_DIR/tests/skill-integration.test.js" >> "$workspace/integration-tests.log" 2>&1; then + ci_log "PASS" "Integration tests passed" + else + ci_log "FAIL" "Integration tests failed" + exit_code=1 + fi + + # Workflow validation tests + if node "$AGENTS_DIR/tests/workflow-validation.test.js" >> "$workspace/workflow-tests.log" 2>&1; then + ci_log "PASS" "Workflow validation tests passed" + else + ci_log "FAIL" "Workflow validation tests failed" + exit_code=1 + fi + + # 3. Comprehensive validation + ci_log "INFO" "Running comprehensive validation..." + + # Health monitoring + if node "$AGENTS_DIR/scripts/health-monitor.js" >> "$workspace/health-check.log" 2>&1; then + ci_log "PASS" "Health monitoring passed" + else + ci_log "FAIL" "Health monitoring failed" + exit_code=1 + fi + + # Advanced validation + if node "$AGENTS_DIR/scripts/advanced-validator.js" >> "$workspace/advanced-validation.log" 2>&1; then + ci_log "PASS" "Advanced validation passed" + else + ci_log "WARN" "Advanced validation found issues" + fi + + # Dependency validation + if node "$AGENTS_DIR/scripts/dependency-validator.js" >> "$workspace/dependency-validation.log" 2>&1; then + ci_log "PASS" "Dependency validation passed" + else + ci_log "WARN" "Dependency validation found issues" + fi + + # Performance monitoring + if node "$AGENTS_DIR/scripts/performance-monitor.js" >> "$workspace/performance-monitor.log" 2>&1; then + ci_log "PASS" "Performance monitoring passed" + else + ci_log "WARN" "Performance monitoring found issues" + fi + + # 4. Generate artifacts + ci_log "INFO" "Generating CI artifacts..." + + local pipeline_end=$(date +%s) + local duration=$((pipeline_end - pipeline_start)) + + # Consolidated report + local report_file="$workspace/ci-pipeline-report.json" + cat > "$report_file" << EOF +{ + "timestamp": "$(date -Iseconds)", + "pipeline_type": "full_ci", + "duration_seconds": $duration, + "exit_code": $exit_code, + "environment": { + "node_version": "$(node --version)", + "platform": "$(uname -s)", + "working_directory": "$BASE_DIR" + }, + "checks_performed": [ + "environment_validation", + "integration_tests", + "workflow_validation_tests", + "health_monitoring", + "advanced_validation", + "dependency_validation", + "performance_monitoring" + ], + "artifacts": [ + "integration-tests.log", + "workflow-tests.log", + "health-check.log", + "advanced-validation.log", + "dependency-validation.log", + "performance-monitor.log", + "npm-install.log" + ], + "workspace": "$workspace" +} +EOF + + ci_log "INFO" "CI pipeline report saved to: $report_file" + ci_log "INFO" "CI artifacts saved to: $workspace" + ci_log "INFO" "Pipeline duration: ${duration}s" + + if [ $exit_code -eq 0 ]; then + ci_log "PASS" "CI pipeline completed successfully" + else + ci_log "FAIL" "CI pipeline failed" + fi + + return $exit_code +} + +# Install Git hooks +install_git_hooks() { + ci_log "INFO" "Installing Git hooks..." + + local hooks_dir="$BASE_DIR/.git/hooks" + local agents_hooks_dir="$AGENTS_DIR/scripts/git-hooks" + + # Create git-hooks directory + mkdir -p "$agents_hooks_dir" + + # Create pre-commit hook + cat > "$agents_hooks_dir/pre-commit" << 'EOF' +#!/bin/bash +# Pre-commit hook for .agents validation +echo "Running .agents pre-commit validation..." +if bash .agents/scripts/ci-hooks.sh pre-commit; then + echo "Pre-commit validation passed" + exit 0 +else + echo "Pre-commit validation failed" + exit 1 +fi +EOF + + # Create pre-push hook + cat > "$agents_hooks_dir/pre-push" << 'EOF' +#!/bin/bash +# Pre-push hook for .agents validation +echo "Running .agents pre-push validation..." +if bash .agents/scripts/ci-hooks.sh pre-push; then + echo "Pre-push validation passed" + exit 0 +else + echo "Pre-push validation failed" + exit 1 +fi +EOF + + # Make hooks executable + chmod +x "$agents_hooks_dir/pre-commit" + chmod +x "$agents_hooks_dir/pre-push" + + # Install hooks if .git directory exists + if [ -d "$hooks_dir" ]; then + cp "$agents_hooks_dir/pre-commit" "$hooks_dir/" + cp "$agents_hooks_dir/pre-push" "$hooks_dir/" + ci_log "PASS" "Git hooks installed successfully" + else + ci_log "WARN" "Git repository not found, hooks copied to .agents/scripts/git-hooks" + fi +} + +# Main function +main() { + local command="${1:-help}" + + case "$command" in + "pre-commit") + pre_commit_hook + ;; + "pre-push") + pre_push_hook + ;; + "ci-pipeline") + ci_pipeline_hook + ;; + "install-hooks") + install_git_hooks + ;; + "help"|*) + echo "Usage: $0 {pre-commit|pre-push|ci-pipeline|install-hooks|help}" + echo "" + echo "Commands:" + echo " pre-commit - Run pre-commit validation" + echo " pre-push - Run pre-push validation" + echo " ci-pipeline - Run full CI pipeline" + echo " install-hooks - Install Git hooks" + echo " help - Show this help" + ;; + esac +} + +# Run main function with all arguments +main "$@" diff --git a/.agents/scripts/dependency-validator.js b/.agents/scripts/dependency-validator.js new file mode 100644 index 0000000..91287fc --- /dev/null +++ b/.agents/scripts/dependency-validator.js @@ -0,0 +1,457 @@ +#!/usr/bin/env node + +/** + * dependency-validator.js - Skill dependency validation system + * Part of LCBP3-DMS Phase 3 enhancements + */ + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +// Configuration +const BASE_DIR = path.resolve(__dirname, '../..'); +const AGENTS_DIR = path.join(BASE_DIR, '.agents'); +const SKILLS_DIR = path.join(AGENTS_DIR, 'skills'); +const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows'); + +// Dependency validation class +class DependencyValidator { + constructor() { + this.validationResults = { + timestamp: new Date().toISOString(), + dependency_graph: {}, + circular_dependencies: [], + missing_dependencies: [], + orphaned_skills: [], + dependency_chains: {}, + validation_summary: { + total_skills: 0, + skills_with_dependencies: 0, + circular_dependencies_found: 0, + missing_dependencies_found: 0, + orphaned_skills_found: 0, + max_dependency_depth: 0, + validation_status: 'unknown' + } + }; + } + + log(message, level = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + critical: '\x1b[35m', // Magenta + reset: '\x1b[0m' + }; + + const color = colors[level] || colors.info; + console.log(`${color}[${level.toUpperCase()}] ${message}${colors.reset}`); + } + + extractSkillDependencies(skillPath, skillName) { + const skillMdPath = path.join(skillPath, 'SKILL.md'); + + if (!fs.existsSync(skillMdPath)) { + this.log(`No SKILL.md found for ${skillName}`, 'warn'); + return { dependencies: [], handoffs: [], error: 'SKILL.md not found' }; + } + + try { + const content = fs.readFileSync(skillMdPath, 'utf8'); + + // Extract dependencies from front matter + let dependencies = []; + let handoffs = []; + + const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (frontMatterMatch) { + try { + const frontMatter = yaml.load(frontMatterMatch[1]); + + // Handle depends-on field + if (frontMatter['depends-on']) { + if (Array.isArray(frontMatter['depends-on'])) { + dependencies = frontMatter['depends-on']; + } else { + dependencies = [frontMatter['depends-on']]; + } + } + + // Handle handoffs field + if (frontMatter.handoffs && Array.isArray(frontMatter.handoffs)) { + handoffs = frontMatter.handoffs.map(h => h.agent); + } + + } catch (yamlError) { + this.log(`Invalid YAML in ${skillName} front matter: ${yamlError.message}`, 'warn'); + } + } + + // Also extract skill references from content + const contentSkillRefs = content.match(/@speckit-\w+/g) || []; + const contentDependencies = contentSkillRefs.map(ref => ref.replace('@', '')); + + // Merge dependencies (avoid duplicates) + const allDependencies = [...new Set([...dependencies, ...contentDependencies])]; + + return { + dependencies: allDependencies, + handoffs: handoffs, + content_references: contentSkillRefs, + front_matter_dependencies: dependencies, + error: null + }; + + } catch (error) { + this.log(`Error reading ${skillName}: ${error.message}`, 'warn'); + return { dependencies: [], handoffs: [], error: error.message }; + } + } + + buildDependencyGraph() { + this.log('Building dependency graph...', 'info'); + + if (!fs.existsSync(SKILLS_DIR)) { + this.log('Skills directory not found', 'fail'); + return; + } + + const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => { + const itemPath = path.join(SKILLS_DIR, item); + return fs.statSync(itemPath).isDirectory(); + }); + + this.validationResults.validation_summary.total_skills = skillDirs.length; + + // Extract dependencies for each skill + for (const skillDir of skillDirs) { + const skillPath = path.join(SKILLS_DIR, skillDir); + const dependencyInfo = this.extractSkillDependencies(skillPath, skillDir); + + this.validationResults.dependency_graph[skillDir] = dependencyInfo; + + if (dependencyInfo.dependencies.length > 0 || dependencyInfo.handoffs.length > 0) { + this.validationResults.validation_summary.skills_with_dependencies++; + } + } + + this.log(`Analyzed ${skillDirs.length} skills`, 'info'); + this.log(`Skills with dependencies: ${this.validationResults.validation_summary.skills_with_dependencies}`, 'info'); + } + + validateDependencies() { + this.log('Validating dependencies...', 'info'); + + const { dependency_graph } = this.validationResults; + const allSkills = Object.keys(dependency_graph); + + // Check for missing dependencies + for (const [skillName, dependencyInfo] of Object.entries(dependency_graph)) { + for (const dependency of dependencyInfo.dependencies) { + if (!allSkills.includes(dependency)) { + this.validationResults.missing_dependencies.push({ + skill: skillName, + missing_dependency: dependency, + dependency_type: 'depends-on' + }); + this.validationResults.validation_summary.missing_dependencies_found++; + this.log(`Missing dependency: ${skillName} depends on ${dependency}`, 'fail'); + } + } + + for (const handoff of dependencyInfo.handoffs) { + if (!allSkills.includes(handoff)) { + this.validationResults.missing_dependencies.push({ + skill: skillName, + missing_dependency: handoff, + dependency_type: 'handoff' + }); + this.validationResults.validation_summary.missing_dependencies_found++; + this.log(`Missing handoff: ${skillName} hands off to ${handoff}`, 'fail'); + } + } + } + + // Check for orphaned skills (no one depends on them) + const dependedOnSkills = new Set(); + for (const dependencyInfo of Object.values(dependency_graph)) { + dependencyInfo.dependencies.forEach(dep => dependedOnSkills.add(dep)); + dependencyInfo.handoffs.forEach(handoff => dependedOnSkills.add(handoff)); + } + + for (const skill of allSkills) { + if (!dependedOnSkills.has(skill) && skill !== 'speckit-constitution') { + // Constitution is allowed to be orphaned (it's a starting point) + this.validationResults.orphaned_skills.push(skill); + this.validationResults.validation_summary.orphaned_skills_found++; + this.log(`Orphaned skill: ${skill} (no dependencies on it)`, 'warn'); + } + } + } + + detectCircularDependencies() { + this.log('Detecting circular dependencies...', 'info'); + + const { dependency_graph } = this.validationResults; + const visited = new Set(); + const recursionStack = new Set(); + const circularDeps = []; + + function dfs(skillName, path = []) { + if (recursionStack.has(skillName)) { + // Found circular dependency + const cycleStart = path.indexOf(skillName); + const cycle = path.slice(cycleStart).concat(skillName); + circularDeps.push(cycle); + return; + } + + if (visited.has(skillName)) { + return; + } + + visited.add(skillName); + recursionStack.add(skillName); + path.push(skillName); + + const dependencyInfo = dependency_graph[skillName]; + if (dependencyInfo) { + for (const dependency of dependencyInfo.dependencies) { + dfs(dependency, [...path]); + } + } + + recursionStack.delete(skillName); + } + + // Run DFS from each skill + for (const skillName of Object.keys(dependency_graph)) { + if (!visited.has(skillName)) { + dfs(skillName); + } + } + + this.validationResults.circular_dependencies = circularDeps; + this.validationResults.validation_summary.circular_dependencies_found = circularDeps.length; + + if (circularDeps.length > 0) { + this.log(`Found ${circularDeps.length} circular dependencies:`, 'critical'); + circularDeps.forEach((cycle, index) => { + this.log(` ${index + 1}. ${cycle.join(' -> ')}`, 'critical'); + }); + } else { + this.log('No circular dependencies found', 'pass'); + } + } + + calculateDependencyChains() { + this.log('Calculating dependency chains...', 'info'); + + const { dependency_graph } = this.validationResults; + const chains = {}; + + function calculateDepth(skillName, visited = new Set()) { + if (visited.has(skillName)) { + return 0; // Circular dependency protection + } + + visited.add(skillName); + + const dependencyInfo = dependency_graph[skillName]; + if (!dependencyInfo || dependencyInfo.dependencies.length === 0) { + return 1; + } + + let maxDepth = 0; + for (const dependency of dependencyInfo.dependencies) { + const depth = calculateDepth(dependency, new Set(visited)); + maxDepth = Math.max(maxDepth, depth); + } + + return maxDepth + 1; + } + + function getDependencyChain(skillName) { + const dependencyInfo = dependency_graph[skillName]; + if (!dependencyInfo || dependencyInfo.dependencies.length === 0) { + return [skillName]; + } + + const chains = []; + for (const dependency of dependencyInfo.dependencies) { + const depChain = getDependencyChain(dependency); + chains.push(depChain.concat(skillName)); + } + + // Return the longest chain + return chains.reduce((longest, current) => + current.length > longest.length ? current : longest, [skillName] + ); + } + + for (const skillName of Object.keys(dependency_graph)) { + const depth = calculateDepth(skillName); + const chain = getDependencyChain(skillName); + + chains[skillName] = { + depth: depth, + chain: chain, + chain_length: chain.length + }; + } + + this.validationResults.dependency_chains = chains; + + const maxDepth = Math.max(...Object.values(chains).map(c => c.depth)); + this.validationResults.validation_summary.max_dependency_depth = maxDepth; + + this.log(`Maximum dependency depth: ${maxDepth}`, 'info'); + } + + validateWorkflowDependencies() { + this.log('Validating workflow dependencies...', 'info'); + + if (!fs.existsSync(WORKFLOWS_DIR)) { + this.log('Workflows directory not found', 'warn'); + return; + } + + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter(file => file.endsWith('.md')); + const allSkills = Object.keys(this.validationResults.dependency_graph); + + for (const workflowFile of workflowFiles) { + const workflowPath = path.join(WORKFLOWS_DIR, workflowFile); + + try { + const content = fs.readFileSync(workflowPath, 'utf8'); + const skillReferences = content.match(/@speckit-\w+/g) || []; + + for (const skillRef of skillReferences) { + const skillName = skillRef.replace('@', ''); + + if (!allSkills.includes(skillName)) { + this.validationResults.missing_dependencies.push({ + workflow: workflowFile, + missing_dependency: skillName, + dependency_type: 'workflow-reference' + }); + this.validationResults.validation_summary.missing_dependencies_found++; + this.log(`Workflow ${workflowFile} references missing skill: ${skillRef}`, 'fail'); + } + } + + } catch (error) { + this.log(`Error reading workflow ${workflowFile}: ${error.message}`, 'warn'); + } + } + } + + generateDependencyReport() { + this.log('Generating dependency report...', 'info'); + + // Determine overall validation status + const summary = this.validationResults.validation_summary; + + if (summary.circular_dependencies_found > 0) { + summary.validation_status = 'critical'; + } else if (summary.missing_dependencies_found > 0) { + summary.validation_status = 'failed'; + } else if (summary.orphaned_skills_found > 0) { + summary.validation_status = 'warning'; + } else { + summary.validation_status = 'passed'; + } + + // Save report + const reportPath = path.join(AGENTS_DIR, 'reports', 'dependency-validation.json'); + const reportsDir = path.dirname(reportPath); + + if (!fs.existsSync(reportsDir)) { + fs.mkdirSync(reportsDir, { recursive: true }); + } + + fs.writeFileSync(reportPath, JSON.stringify(this.validationResults, null, 2)); + this.log(`Dependency validation report saved to: ${reportPath}`, 'info'); + } + + printSummary() { + const summary = this.validationResults.validation_summary; + + this.log('=== Dependency Validation Summary ===', 'info'); + this.log(`Total skills: ${summary.total_skills}`, 'info'); + this.log(`Skills with dependencies: ${summary.skills_with_dependencies}`, 'info'); + this.log(`Circular dependencies: ${summary.circular_dependencies_found}`, summary.circular_dependencies_found > 0 ? 'critical' : 'pass'); + this.log(`Missing dependencies: ${summary.missing_dependencies_found}`, summary.missing_dependencies_found > 0 ? 'fail' : 'pass'); + this.log(`Orphaned skills: ${summary.orphaned_skills_found}`, summary.orphaned_skills_found > 0 ? 'warn' : 'info'); + this.log(`Max dependency depth: ${summary.max_dependency_depth}`, 'info'); + this.log(`Validation status: ${summary.validation_status.toUpperCase()}`, + summary.validation_status === 'passed' ? 'pass' : + summary.validation_status === 'warning' ? 'warn' : 'fail'); + + // Show longest dependency chains + const chains = this.validationResults.dependency_chains; + const sortedChains = Object.entries(chains) + .sort(([,a], [,b]) => b.depth - a.depth) + .slice(0, 3); + + if (sortedChains.length > 0) { + this.log('Top 3 longest dependency chains:', 'info'); + sortedChains.forEach(([skillName, chainInfo], index) => { + this.log(` ${index + 1}. ${chainInfo.chain.join(' -> ')} (depth: ${chainInfo.depth})`, 'info'); + }); + } + } + + async runDependencyValidation() { + this.log('Starting dependency validation...', 'info'); + this.log(`Base directory: ${BASE_DIR}`, 'info'); + + // Build dependency graph + this.buildDependencyGraph(); + + // Validate dependencies + this.validateDependencies(); + + // Detect circular dependencies + this.detectCircularDependencies(); + + // Calculate dependency chains + this.calculateDependencyChains(); + + // Validate workflow dependencies + this.validateWorkflowDependencies(); + + // Generate report + this.generateDependencyReport(); + + // Print summary + this.printSummary(); + + return this.validationResults; + } +} + +// CLI interface +async function main() { + const validator = new DependencyValidator(); + + try { + const results = await validator.runDependencyValidation(); + const status = results.validation_summary.validation_status; + process.exit(status === 'passed' || status === 'warning' ? 0 : 1); + } catch (error) { + console.error('Dependency validation failed:', error); + process.exit(1); + } +} + +// Export for use in other modules +module.exports = { DependencyValidator }; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/.agents/scripts/health-monitor.js b/.agents/scripts/health-monitor.js new file mode 100644 index 0000000..3890985 --- /dev/null +++ b/.agents/scripts/health-monitor.js @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +/** + * health-monitor.js - Automated health monitoring system for .agents + * Part of LCBP3-DMS Phase 3 enhancements + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Configuration +const BASE_DIR = path.resolve(__dirname, '../..'); +const AGENTS_DIR = path.join(BASE_DIR, '.agents'); +const HEALTH_LOG_PATH = path.join(AGENTS_DIR, 'logs', 'health.log'); +const HEALTH_REPORT_PATH = path.join(AGENTS_DIR, 'reports', 'health-report.json'); + +// Ensure directories exist +[ path.dirname(HEALTH_LOG_PATH), path.dirname(HEALTH_REPORT_PATH) ].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +// Health monitoring class +class HealthMonitor { + constructor() { + this.startTime = new Date(); + this.metrics = { + timestamp: this.startTime.toISOString(), + version: '1.8.6', + checks: {}, + summary: { + total_checks: 0, + passed_checks: 0, + failed_checks: 0, + warnings: 0, + overall_health: 'unknown' + } + }; + } + + log(message, level = 'info') { + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; + + // Console output with colors + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m' + }; + + const color = colors[level] || colors.info; + console.log(`${color}${logEntry.trim()}${colors.reset}`); + + // File logging + fs.appendFileSync(HEALTH_LOG_PATH, logEntry); + } + + checkDirectoryExists(dirPath, checkName) { + this.metrics.summary.total_checks++; + const exists = fs.existsSync(dirPath); + + this.metrics.checks[checkName] = { + type: 'directory_exists', + status: exists ? 'pass' : 'fail', + path: dirPath, + message: exists ? 'Directory exists' : 'Directory missing' + }; + + if (exists) { + this.metrics.summary.passed_checks++; + this.log(`${checkName}: PASS - Directory exists`, 'pass'); + } else { + this.metrics.summary.failed_checks++; + this.log(`${checkName}: FAIL - Directory missing: ${dirPath}`, 'fail'); + } + + return exists; + } + + checkFileExists(filePath, checkName) { + this.metrics.summary.total_checks++; + const exists = fs.existsSync(filePath); + + this.metrics.checks[checkName] = { + type: 'file_exists', + status: exists ? 'pass' : 'fail', + path: filePath, + message: exists ? 'File exists' : 'File missing' + }; + + if (exists) { + this.metrics.summary.passed_checks++; + this.log(`${checkName}: PASS - File exists`, 'pass'); + } else { + this.metrics.summary.failed_checks++; + this.log(`${checkName}: FAIL - File missing: ${filePath}`, 'fail'); + } + + return exists; + } + + checkFileVersion(filePath, expectedVersion, checkName) { + this.metrics.summary.total_checks++; + + if (!fs.existsSync(filePath)) { + this.metrics.summary.failed_checks++; + this.metrics.checks[checkName] = { + type: 'version_check', + status: 'fail', + path: filePath, + message: 'File does not exist' + }; + this.log(`${checkName}: FAIL - File not found: ${filePath}`, 'fail'); + return false; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const versionMatch = content.match(/v?(\d+\.\d+\.\d+)/); + const actualVersion = versionMatch ? versionMatch[1] : 'not_found'; + const versionMatches = actualVersion === expectedVersion; + + this.metrics.checks[checkName] = { + type: 'version_check', + status: versionMatches ? 'pass' : 'fail', + path: filePath, + expected_version: expectedVersion, + actual_version: actualVersion, + message: versionMatches ? 'Version matches' : `Version mismatch (expected ${expectedVersion}, found ${actualVersion})` + }; + + if (versionMatches) { + this.metrics.summary.passed_checks++; + this.log(`${checkName}: PASS - Version ${actualVersion}`, 'pass'); + } else { + this.metrics.summary.failed_checks++; + this.log(`${checkName}: FAIL - Version mismatch (expected ${expectedVersion}, found ${actualVersion})`, 'fail'); + } + + return versionMatches; + } catch (error) { + this.metrics.summary.failed_checks++; + this.metrics.checks[checkName] = { + type: 'version_check', + status: 'fail', + path: filePath, + message: `Error reading file: ${error.message}` + }; + this.log(`${checkName}: FAIL - Error reading file: ${error.message}`, 'fail'); + return false; + } + } + + checkSkillHealth() { + this.log('Checking skill health...', 'info'); + const skillsDir = path.join(AGENTS_DIR, 'skills'); + + if (!fs.existsSync(skillsDir)) { + this.log('Skills directory not found', 'fail'); + return; + } + + const skillDirs = fs.readdirSync(skillsDir).filter(item => { + const itemPath = path.join(skillsDir, item); + return fs.statSync(itemPath).isDirectory(); + }); + + this.metrics.checks['skill_count'] = { + type: 'skill_count', + status: skillDirs.length >= 20 ? 'pass' : 'warn', + count: skillDirs.length, + expected: 20, + message: `Found ${skillDirs.length} skills (expected at least 20)` + }; + + if (skillDirs.length >= 20) { + this.metrics.summary.passed_checks++; + this.log(`Skill count: PASS - Found ${skillDirs.length} skills`, 'pass'); + } else { + this.metrics.summary.warnings++; + this.log(`Skill count: WARN - Only ${skillDirs.length} skills found (expected at least 20)`, 'warn'); + } + + // Check individual skills + let healthySkills = 0; + skillDirs.forEach(skillDir => { + const skillPath = path.join(skillsDir, skillDir); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + + if (fs.existsSync(skillMdPath)) { + try { + const content = fs.readFileSync(skillMdPath, 'utf8'); + const hasName = content.includes('name:'); + const hasDescription = content.includes('description:'); + const hasVersion = content.includes('version:'); + const hasRole = content.includes('## Role'); + const hasTask = content.includes('## Task'); + + const isHealthy = hasName && hasDescription && hasVersion && hasRole && hasTask; + if (isHealthy) healthySkills++; + + this.metrics.checks[`skill_${skillDir}_health`] = { + type: 'skill_health', + status: isHealthy ? 'pass' : 'fail', + skill: skillDir, + has_name: hasName, + has_description: hasDescription, + has_version: hasVersion, + has_role: hasRole, + has_task: hasTask, + message: isHealthy ? 'Skill is healthy' : 'Skill has missing sections' + }; + } catch (error) { + this.metrics.checks[`skill_${skillDir}_health`] = { + type: 'skill_health', + status: 'fail', + skill: skillDir, + message: `Error reading skill: ${error.message}` + }; + } + } + }); + + this.metrics.summary.total_checks++; + if (healthySkills === skillDirs.length) { + this.metrics.summary.passed_checks++; + this.log(`Individual skills: PASS - All ${healthySkills} skills are healthy`, 'pass'); + } else { + this.metrics.summary.failed_checks++; + this.log(`Individual skills: FAIL - Only ${healthySkills}/${skillDirs.length} skills are healthy`, 'fail'); + } + } + + checkWorkflowHealth() { + this.log('Checking workflow health...', 'info'); + const workflowsDir = path.join(BASE_DIR, '.windsurf', 'workflows'); + + if (!fs.existsSync(workflowsDir)) { + this.log('Workflows directory not found', 'fail'); + return; + } + + const workflowFiles = fs.readdirSync(workflowsDir).filter(file => file.endsWith('.md')); + + this.metrics.checks['workflow_count'] = { + type: 'workflow_count', + status: workflowFiles.length >= 20 ? 'pass' : 'warn', + count: workflowFiles.length, + expected: 20, + message: `Found ${workflowFiles.length} workflows (expected at least 20)` + }; + + if (workflowFiles.length >= 20) { + this.metrics.summary.passed_checks++; + this.log(`Workflow count: PASS - Found ${workflowFiles.length} workflows`, 'pass'); + } else { + this.metrics.summary.warnings++; + this.log(`Workflow count: WARN - Only ${workflowFiles.length} workflows found (expected at least 20)`, 'warn'); + } + } + + calculateOverallHealth() { + const { total_checks, passed_checks, failed_checks, warnings } = this.metrics.summary; + + if (failed_checks === 0) { + this.metrics.summary.overall_health = warnings === 0 ? 'excellent' : 'good'; + } else if (failed_checks <= total_checks * 0.1) { + this.metrics.summary.overall_health = 'fair'; + } else { + this.metrics.summary.overall_health = 'poor'; + } + + this.log(`Overall health: ${this.metrics.summary.overall_health}`, 'info'); + } + + generateReport() { + const report = { + ...this.metrics, + duration: new Date() - this.startTime, + environment: { + node_version: process.version, + platform: process.platform, + agents_dir: AGENTS_DIR + } + }; + + fs.writeFileSync(HEALTH_REPORT_PATH, JSON.stringify(report, null, 2)); + this.log(`Health report saved to: ${HEALTH_REPORT_PATH}`, 'info'); + + return report; + } + + async runFullHealthCheck() { + this.log('Starting comprehensive health check...', 'info'); + this.log(`Base directory: ${BASE_DIR}`, 'info'); + + // Core directory checks + this.checkDirectoryExists(AGENTS_DIR, 'agents_directory'); + this.checkDirectoryExists(path.join(AGENTS_DIR, 'skills'), 'skills_directory'); + this.checkDirectoryExists(path.join(AGENTS_DIR, 'scripts'), 'scripts_directory'); + this.checkDirectoryExists(path.join(AGENTS_DIR, 'rules'), 'rules_directory'); + this.checkDirectoryExists(path.join(BASE_DIR, '.windsurf', 'workflows'), 'workflows_directory'); + + // Core file checks + this.checkFileExists(path.join(AGENTS_DIR, 'README.md'), 'readme_file'); + this.checkFileExists(path.join(AGENTS_DIR, 'skills', 'VERSION'), 'skills_version_file'); + this.checkFileExists(path.join(AGENTS_DIR, 'skills', 'skills.md'), 'skills_documentation'); + + // Version consistency checks + this.checkFileVersion(path.join(AGENTS_DIR, 'README.md'), '1.8.6', 'readme_version'); + this.checkFileVersion(path.join(AGENTS_DIR, 'skills', 'VERSION'), '1.8.6', 'skills_version_file_version'); + this.checkFileVersion(path.join(AGENTS_DIR, 'skills', 'skills.md'), '1.8.6', 'skills_documentation_version'); + this.checkFileVersion(path.join(AGENTS_DIR, 'rules', '00-project-context.md'), '1.8.6', 'project_context_version'); + + // Script availability checks + this.checkFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'), 'bash_version_script'); + this.checkFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'audit-skills.sh'), 'bash_audit_script'); + this.checkFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'sync-workflows.sh'), 'bash_sync_script'); + this.checkFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'validate-versions.ps1'), 'powershell_version_script'); + this.checkFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'audit-skills.ps1'), 'powershell_audit_script'); + + // Detailed health checks + this.checkSkillHealth(); + this.checkWorkflowHealth(); + + // Calculate overall health + this.calculateOverallHealth(); + + // Generate report + const report = this.generateReport(); + + // Summary + this.log('=== Health Check Summary ===', 'info'); + this.log(`Total checks: ${this.metrics.summary.total_checks}`, 'info'); + this.log(`Passed: ${this.metrics.summary.passed_checks}`, 'pass'); + this.log(`Failed: ${this.metrics.summary.failed_checks}`, this.metrics.summary.failed_checks > 0 ? 'fail' : 'info'); + this.log(`Warnings: ${this.metrics.summary.warnings}`, 'warn'); + this.log(`Overall health: ${this.metrics.summary.overall_health}`, 'info'); + this.log(`Duration: ${new Date() - this.startTime}ms`, 'info'); + + return report; + } +} + +// CLI interface +async function main() { + const monitor = new HealthMonitor(); + + try { + const report = await monitor.runFullHealthCheck(); + process.exit(report.summary.failed_checks > 0 ? 1 : 0); + } catch (error) { + console.error('Health check failed:', error); + process.exit(1); + } +} + +// Export for use in other modules +module.exports = { HealthMonitor }; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/.agents/scripts/performance-monitor.js b/.agents/scripts/performance-monitor.js new file mode 100644 index 0000000..ff5d29a --- /dev/null +++ b/.agents/scripts/performance-monitor.js @@ -0,0 +1,494 @@ +#!/usr/bin/env node + +/** + * performance-monitor.js - Performance monitoring for .agents skills + * Part of LCBP3-DMS Phase 3 enhancements + */ + +const fs = require('fs'); +const path = require('path'); +const { performance } = require('perf_hooks'); + +// Configuration +const BASE_DIR = path.resolve(__dirname, '../..'); +const AGENTS_DIR = path.join(BASE_DIR, '.agents'); +const SKILLS_DIR = path.join(AGENTS_DIR, 'skills'); +const PERFORMANCE_LOG_PATH = path.join(AGENTS_DIR, 'logs', 'performance.log'); +const PERFORMANCE_REPORT_PATH = path.join(AGENTS_DIR, 'reports', 'performance-report.json'); + +// Ensure directories exist +[ path.dirname(PERFORMANCE_LOG_PATH), path.dirname(PERFORMANCE_REPORT_PATH) ].forEach(dir => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +// Performance monitoring class +class PerformanceMonitor { + constructor() { + this.startTime = performance.now(); + this.metrics = { + timestamp: new Date().toISOString(), + duration: 0, + skill_metrics: {}, + workflow_metrics: {}, + system_metrics: {}, + summary: { + total_skills_analyzed: 0, + total_workflows_analyzed: 0, + average_skill_size: 0, + average_workflow_size: 0, + performance_score: 0, + recommendations: [] + } + }; + } + + log(message, level = 'info') { + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; + + // Console output with colors + const colors = { + info: '\x1b[36m', // Cyan + good: '\x1b[32m', // Green + warn: '\x1b[33m', // Yellow + poor: '\x1b[31m', // Red + reset: '\x1b[0m' + }; + + const color = colors[level] || colors.info; + console.log(`${color}${logEntry.trim()}${colors.reset}`); + + // File logging + fs.appendFileSync(PERFORMANCE_LOG_PATH, logEntry); + } + + analyzeSkillPerformance(skillPath, skillName) { + const skillMdPath = path.join(skillPath, 'SKILL.md'); + + if (!fs.existsSync(skillMdPath)) { + this.log(`Skipping ${skillName} - SKILL.md not found`, 'warn'); + return null; + } + + const startTime = performance.now(); + + try { + const stats = fs.statSync(skillMdPath); + const content = fs.readFileSync(skillMdPath, 'utf8'); + + // Basic metrics + const fileSizeKB = stats.size / 1024; + const lineCount = content.split('\n').length; + const wordCount = content.split(/\s+/).filter(word => word.length > 0).length; + const charCount = content.length; + + // Content complexity metrics + const sectionCount = (content.match(/^#+\s/gm) || []).length; + const codeBlockCount = (content.match(/```[\s\S]*?```/g) || []).length; + const listCount = (content.match(/^[-*+]\s/gm) || []).length; + + // Front matter analysis + const frontMatterMatch = content.match(/^---\n([\s\S]*?)\n---/); + const frontMatterSize = frontMatterMatch ? frontMatterMatch[1].length : 0; + const hasFrontMatter = frontMatterMatch !== null; + + // Readability metrics + const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 0); + const avgWordsPerSentence = sentences.length > 0 ? wordCount / sentences.length : 0; + const avgCharsPerWord = wordCount > 0 ? charCount / wordCount : 0; + + // Performance score calculation + let performanceScore = 100; + + // Size penalties + if (fileSizeKB > 50) performanceScore -= 10; + if (fileSizeKB > 100) performanceScore -= 20; + + // Content quality bonuses + if (hasFrontMatter) performanceScore += 5; + if (sectionCount >= 3) performanceScore += 5; + if (codeBlockCount > 0) performanceScore += 5; + + // Readability penalties + if (avgWordsPerSentence > 25) performanceScore -= 5; + if (avgWordsPerSentence > 35) performanceScore -= 10; + + const analysisTime = performance.now() - startTime; + + const skillMetrics = { + skill_name: skillName, + file_path: skillMdPath, + file_size_kb: Math.round(fileSizeKB * 100) / 100, + line_count: lineCount, + word_count: wordCount, + char_count: charCount, + section_count: sectionCount, + code_block_count: codeBlockCount, + list_count: listCount, + front_matter_size: frontMatterSize, + has_front_matter: hasFrontMatter, + avg_words_per_sentence: Math.round(avgWordsPerSentence * 100) / 100, + avg_chars_per_word: Math.round(avgCharsPerWord * 100) / 100, + performance_score: Math.max(0, Math.min(100, performanceScore)), + analysis_time_ms: Math.round(analysisTime * 100) / 100, + last_modified: stats.mtime.toISOString() + }; + + this.metrics.skill_metrics[skillName] = skillMetrics; + + // Log performance assessment + if (performanceScore >= 80) { + this.log(`${skillName}: GOOD performance (score: ${performanceScore})`, 'good'); + } else if (performanceScore >= 60) { + this.log(`${skillName}: OK performance (score: ${performanceScore})`, 'info'); + } else { + this.log(`${skillName}: POOR performance (score: ${performanceScore})`, 'poor'); + } + + return skillMetrics; + + } catch (error) { + this.log(`Error analyzing ${skillName}: ${error.message}`, 'warn'); + return null; + } + } + + analyzeWorkflowPerformance(workflowPath, workflowName) { + const startTime = performance.now(); + + if (!fs.existsSync(workflowPath)) { + this.log(`Skipping workflow ${workflowName} - file not found`, 'warn'); + return null; + } + + try { + const stats = fs.statSync(workflowPath); + const content = fs.readFileSync(workflowPath, 'utf8'); + + // Basic metrics + const fileSizeKB = stats.size / 1024; + const lineCount = content.split('\n').length; + const wordCount = content.split(/\s+/).filter(word => word.length > 0).length; + + // Workflow-specific metrics + const stepCount = (content.match(/^\d+\./gm) || []).length; + const codeBlockCount = (content.match(/```[\s\S]*?```/g) || []).length; + const skillReferences = (content.match(/@speckit-\w+/g) || []).length; + + // Performance score calculation + let performanceScore = 100; + + // Size penalties + if (fileSizeKB > 20) performanceScore -= 10; + if (fileSizeKB > 50) performanceScore -= 20; + + // Content quality bonuses + if (stepCount > 0) performanceScore += 10; + if (codeBlockCount > 0) performanceScore += 5; + if (skillReferences > 0) performanceScore += 5; + + const analysisTime = performance.now() - startTime; + + const workflowMetrics = { + workflow_name: workflowName, + file_path: workflowPath, + file_size_kb: Math.round(fileSizeKB * 100) / 100, + line_count: lineCount, + word_count: wordCount, + step_count: stepCount, + code_block_count: codeBlockCount, + skill_references: skillReferences, + performance_score: Math.max(0, Math.min(100, performanceScore)), + analysis_time_ms: Math.round(analysisTime * 100) / 100, + last_modified: stats.mtime.toISOString() + }; + + this.metrics.workflow_metrics[workflowName] = workflowMetrics; + + // Log performance assessment + if (performanceScore >= 80) { + this.log(`${workflowName}: GOOD performance (score: ${performanceScore})`, 'good'); + } else if (performanceScore >= 60) { + this.log(`${workflowName}: OK performance (score: ${performanceScore})`, 'info'); + } else { + this.log(`${workflowName}: POOR performance (score: ${performanceScore})`, 'poor'); + } + + return workflowMetrics; + + } catch (error) { + this.log(`Error analyzing workflow ${workflowName}: ${error.message}`, 'warn'); + return null; + } + } + + analyzeSystemMetrics() { + this.log('Analyzing system metrics...', 'info'); + + // Directory sizes + const agentsSize = this.getDirectorySize(AGENTS_DIR); + const skillsSize = this.getDirectorySize(SKILLS_DIR); + const workflowsDir = path.join(BASE_DIR, '.windsurf', 'workflows'); + const workflowsSize = fs.existsSync(workflowsDir) ? this.getDirectorySize(workflowsDir) : 0; + + // File counts + const totalFiles = this.countFiles(AGENTS_DIR); + const skillFiles = this.countFiles(SKILLS_DIR); + const workflowFiles = fs.existsSync(workflowsDir) ? this.countFiles(workflowsDir) : 0; + + this.metrics.system_metrics = { + agents_directory_size_kb: Math.round(agentsSize / 1024), + skills_directory_size_kb: Math.round(skillsSize / 1024), + workflows_directory_size_kb: Math.round(workflowsSize / 1024), + total_files: totalFiles, + skill_files: skillFiles, + workflow_files: workflowFiles, + analysis_timestamp: new Date().toISOString() + }; + + this.log(`System: ${totalFiles} files, ${Math.round(agentsSize / 1024)}KB total`, 'info'); + } + + getDirectorySize(dirPath) { + let totalSize = 0; + + if (!fs.existsSync(dirPath)) { + return 0; + } + + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + totalSize += this.getDirectorySize(itemPath); + } else { + totalSize += stats.size; + } + } + + return totalSize; + } + + countFiles(dirPath) { + let fileCount = 0; + + if (!fs.existsSync(dirPath)) { + return 0; + } + + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + fileCount += this.countFiles(itemPath); + } else { + fileCount++; + } + } + + return fileCount; + } + + generateRecommendations() { + const recommendations = []; + const { skill_metrics, workflow_metrics, system_metrics } = this.metrics; + + // Analyze skill performance + const skillScores = Object.values(skill_metrics).map(m => m.performance_score); + const avgSkillScore = skillScores.length > 0 ? skillScores.reduce((a, b) => a + b, 0) / skillScores.length : 0; + + if (avgSkillScore < 70) { + recommendations.push({ + type: 'performance', + priority: 'high', + message: 'Average skill performance is below optimal. Consider optimizing skill documentation.', + details: `Average score: ${Math.round(avgSkillScore)}` + }); + } + + // Check for oversized files + const largeSkills = Object.values(skill_metrics).filter(m => m.file_size_kb > 50); + if (largeSkills.length > 0) { + recommendations.push({ + type: 'size', + priority: 'medium', + message: `${largeSkills.length} skills have large file sizes (>50KB). Consider breaking down complex skills.`, + details: largeSkills.map(s => `${s.skill_name} (${s.file_size_kb}KB)`).join(', ') + }); + } + + // Check for missing front matter + const skillsWithoutFrontMatter = Object.values(skill_metrics).filter(m => !m.has_front_matter); + if (skillsWithoutFrontMatter.length > 0) { + recommendations.push({ + type: 'structure', + priority: 'high', + message: `${skillsWithoutFrontMatter.length} skills missing front matter. Add proper YAML front matter.`, + details: skillsWithoutFrontMatter.map(s => s.skill_name).join(', ') + }); + } + + // Analyze workflow performance + const workflowScores = Object.values(workflow_metrics).map(m => m.performance_score); + const avgWorkflowScore = workflowScores.length > 0 ? workflowScores.reduce((a, b) => a + b, 0) / workflowScores.length : 0; + + if (avgWorkflowScore < 70) { + recommendations.push({ + type: 'performance', + priority: 'medium', + message: 'Average workflow performance could be improved. Add more detailed steps and examples.', + details: `Average score: ${Math.round(avgWorkflowScore)}` + }); + } + + // System recommendations + if (system_metrics.agents_directory_size_kb > 1000) { + recommendations.push({ + type: 'maintenance', + priority: 'low', + message: '.agents directory is growing large. Consider archiving old logs and reports.', + details: `Current size: ${system_metrics.agents_directory_size_kb}KB` + }); + } + + this.metrics.summary.recommendations = recommendations; + + // Log recommendations + if (recommendations.length > 0) { + this.log('Performance Recommendations:', 'info'); + recommendations.forEach((rec, index) => { + const priority = rec.priority === 'high' ? 'HIGH' : rec.priority === 'medium' ? 'MED' : 'LOW'; + this.log(` ${index + 1}. [${priority}] ${rec.message}`, 'warn'); + }); + } else { + this.log('No performance issues detected - system is optimized!', 'good'); + } + } + + calculateOverallPerformance() { + const { skill_metrics, workflow_metrics } = this.metrics; + + const skillScores = Object.values(skill_metrics).map(m => m.performance_score); + const workflowScores = Object.values(workflow_metrics).map(m => m.performance_score); + + const avgSkillScore = skillScores.length > 0 ? skillScores.reduce((a, b) => a + b, 0) / skillScores.length : 100; + const avgWorkflowScore = workflowScores.length > 0 ? workflowScores.reduce((a, b) => a + b, 0) / workflowScores.length : 100; + + // Weight skills more heavily than workflows + const overallScore = (avgSkillScore * 0.7) + (avgWorkflowScore * 0.3); + + this.metrics.summary.performance_score = Math.round(overallScore); + this.metrics.summary.average_skill_size = skillScores.length > 0 + ? Math.round(Object.values(skill_metrics).reduce((sum, m) => sum + m.file_size_kb, 0) / skillScores.length * 100) / 100 + : 0; + this.metrics.summary.average_workflow_size = workflowScores.length > 0 + ? Math.round(Object.values(workflow_metrics).reduce((sum, m) => sum + m.file_size_kb, 0) / workflowScores.length * 100) / 100 + : 0; + this.metrics.summary.total_skills_analyzed = skillScores.length; + this.metrics.summary.total_workflows_analyzed = workflowScores.length; + } + + generateReport() { + this.metrics.duration = performance.now() - this.startTime; + + const report = { + ...this.metrics, + generated_at: new Date().toISOString(), + environment: { + node_version: process.version, + platform: process.platform, + memory_usage: process.memoryUsage() + } + }; + + fs.writeFileSync(PERFORMANCE_REPORT_PATH, JSON.stringify(report, null, 2)); + this.log(`Performance report saved to: ${PERFORMANCE_REPORT_PATH}`, 'info'); + + return report; + } + + async runPerformanceAnalysis() { + this.log('Starting performance analysis...', 'info'); + this.log(`Base directory: ${BASE_DIR}`, 'info'); + + // Analyze skills + this.log('Analyzing skill performance...', 'info'); + if (fs.existsSync(SKILLS_DIR)) { + const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => { + const itemPath = path.join(SKILLS_DIR, item); + return fs.statSync(itemPath).isDirectory(); + }); + + for (const skillDir of skillDirs) { + const skillPath = path.join(SKILLS_DIR, skillDir); + this.analyzeSkillPerformance(skillPath, skillDir); + } + } + + // Analyze workflows + this.log('Analyzing workflow performance...', 'info'); + const workflowsDir = path.join(BASE_DIR, '.windsurf', 'workflows'); + if (fs.existsSync(workflowsDir)) { + const workflowFiles = fs.readdirSync(workflowsDir).filter(file => file.endsWith('.md')); + + for (const workflowFile of workflowFiles) { + const workflowPath = path.join(workflowsDir, workflowFile); + const workflowName = workflowFile.replace('.md', ''); + this.analyzeWorkflowPerformance(workflowPath, workflowName); + } + } + + // System metrics + this.analyzeSystemMetrics(); + + // Calculate overall performance + this.calculateOverallPerformance(); + + // Generate recommendations + this.generateRecommendations(); + + // Generate report + const report = this.generateReport(); + + // Summary + this.log('=== Performance Analysis Summary ===', 'info'); + this.log(`Overall performance score: ${this.metrics.summary.performance_score}/100`, 'info'); + this.log(`Skills analyzed: ${this.metrics.summary.total_skills_analyzed}`, 'info'); + this.log(`Workflows analyzed: ${this.metrics.summary.total_workflows_analyzed}`, 'info'); + this.log(`Average skill size: ${this.metrics.summary.average_skill_size}KB`, 'info'); + this.log(`Average workflow size: ${this.metrics.summary.average_workflow_size}KB`, 'info'); + this.log(`Analysis duration: ${Math.round(this.metrics.duration)}ms`, 'info'); + this.log(`Recommendations: ${this.metrics.summary.recommendations.length}`, 'info'); + + return report; + } +} + +// CLI interface +async function main() { + const monitor = new PerformanceMonitor(); + + try { + const report = await monitor.runPerformanceAnalysis(); + process.exit(report.summary.performance_score < 60 ? 1 : 0); + } catch (error) { + console.error('Performance analysis failed:', error); + process.exit(1); + } +} + +// Export for use in other modules +module.exports = { PerformanceMonitor }; + +// Run if called directly +if (require.main === module) { + main(); +} diff --git a/.agents/scripts/powershell/audit-skills.ps1 b/.agents/scripts/powershell/audit-skills.ps1 new file mode 100644 index 0000000..27003f7 --- /dev/null +++ b/.agents/scripts/powershell/audit-skills.ps1 @@ -0,0 +1,203 @@ +# audit-skills.ps1 - Verify skill completeness and health +# Part of LCBP3-DMS Phase 2 improvements + +param( + [string]$BaseDir = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) +) + +# Colors for output +$Colors = @{ + Red = "`e[0;31m" + Green = "`e[0;32m" + Yellow = "`e[1;33m" + Blue = "`e[0;34m" + NoColor = "`e[0m" +} + +$AgentsDir = Join-Path $BaseDir ".agents" +$SkillsDir = Join-Path $AgentsDir "skills" + +Write-Host "=== Skills Health Audit ===" -ForegroundColor Cyan +Write-Host "Base directory: $BaseDir" +Write-Host "" + +# Function to check if skill has required files +function Test-SkillHealth { + param( + [string]$SkillDir + ) + + $skillName = Split-Path $SkillDir -Leaf + $issues = 0 + + # Check for SKILL.md + $skillFile = Join-Path $SkillDir "SKILL.md" + if (Test-Path $skillFile) { + Write-Host " OK: $skillName/SKILL.md" -ForegroundColor $Colors.Green + } else { + Write-Host " MISSING: $skillName/SKILL.md" -ForegroundColor $Colors.Red + $issues++ + } + + # Check for templates directory (optional) + $templatesDir = Join-Path $SkillDir "templates" + if (Test-Path $templatesDir) { + $templateCount = (Get-ChildItem -Path $templatesDir -Filter "*.md" -File | Measure-Object).Count + if ($templateCount -gt 0) { + Write-Host " OK: $skillName/templates ($templateCount files)" -ForegroundColor $Colors.Green + } else { + Write-Host " EMPTY: $skillName/templates (no files)" -ForegroundColor $Colors.Yellow + } + } + + # Check SKILL.md content if exists + if (Test-Path $skillFile) { + $content = Get-Content $skillFile -Raw + + # Check for required front matter fields + $requiredFields = @("name", "description", "version") + foreach ($field in $requiredFields) { + if ($content -match "^$field:") { + Write-Host " FIELD: $field" -ForegroundColor $Colors.Green + } else { + Write-Host " MISSING FIELD: $field" -ForegroundColor $Colors.Red + $issues++ + } + } + + # Check for Role section + if ($content -match "^## Role$") { + Write-Host " SECTION: Role" -ForegroundColor $Colors.Green + } else { + Write-Host " MISSING SECTION: Role" -ForegroundColor $Colors.Yellow + $issues++ + } + + # Check for Task section + if ($content -match "^## Task$") { + Write-Host " SECTION: Task" -ForegroundColor $Colors.Green + } else { + Write-Host " MISSING SECTION: Task" -ForegroundColor $Colors.Yellow + $issues++ + } + } + + return $issues +} + +# Function to get skill version from SKILL.md +function Get-SkillVersion { + param( + [string]$SkillFile + ) + + if (Test-Path $SkillFile) { + try { + $content = Get-Content $SkillFile -Raw + if ($content -match "^version:\s*(.+)") { + return $matches[1].Trim() + } + } catch { + return "error" + } + } + return "no_file" +} + +# Check skills directory +if (-not (Test-Path $SkillsDir)) { + Write-Host "ERROR: Skills directory not found" -ForegroundColor $Colors.Red + exit 1 +} + +Write-Host "Scanning skills directory: $SkillsDir" +Write-Host "" + +# Get all skill directories +$skillDirs = Get-ChildItem -Path $SkillsDir -Directory | Sort-Object Name + +Write-Host "Found $($skillDirs.Count) skill directories" +Write-Host "" + +# Audit each skill +$totalIssues = 0 +$skillSummary = @() + +foreach ($skillDir in $skillDirs) { + $skillName = $skillDir.Name + Write-Host "Auditing: $skillName" + Write-Host "------------------------" + + $issues = Test-SkillHealth -SkillDir $skillDir.FullName + + $skillVersion = Get-SkillVersion -SkillFile (Join-Path $skillDir.FullName "SKILL.md") + $skillSummary += @{ + Name = $skillName + Issues = $issues + Version = $skillVersion + } + + $totalIssues += $issues + Write-Host "" +} + +# Summary report +Write-Host "=== Skills Audit Summary ===" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Skill Status:" +Write-Host "-----------" +foreach ($summary in $skillSummary) { + if ($summary.Issues -eq 0) { + Write-Host " HEALTHY: $($summary.Name) (v$($summary.Version))" -ForegroundColor $Colors.Green + } else { + Write-Host " ISSUES: $($summary.Name) (v$($summary.Version)) - $($summary.Issues) issues" -ForegroundColor $Colors.Red + } +} + +Write-Host "" + +# Check skills.md version consistency +$skillsVersionFile = Join-Path $SkillsDir "VERSION" +if (Test-Path $skillsVersionFile) { + $content = Get-Content $skillsVersionFile -Raw + if ($content -match "^version:\s*(.+)") { + $globalVersion = $matches[1].Trim() + Write-Host "Global skills version: v$globalVersion" + Write-Host "" + + # Check for version mismatches + Write-Host "Version Consistency Check:" + Write-Host "------------------------" + $versionMismatches = 0 + + foreach ($summary in $skillSummary) { + if ($summary.Version -ne "unknown" -and $summary.Version -ne "no_file" -and $summary.Version -ne $globalVersion) { + Write-Host " MISMATCH: $($summary.Name) is v$($summary.Version), global is v$globalVersion" -ForegroundColor $Colors.Yellow + $versionMismatches++ + } + } + + if ($versionMismatches -eq 0) { + Write-Host " All skills match global version" -ForegroundColor $Colors.Green + } + } +} + +Write-Host "" + +# Overall health +if ($totalIssues -eq 0) { + Write-Host "=== SUCCESS: All skills healthy ===" -ForegroundColor $Colors.Green + Write-Host "Total skills: $($skillDirs.Count)" + exit 0 +} else { + Write-Host "=== ISSUES FOUND: $totalIssues total issues ===" -ForegroundColor $Colors.Red + Write-Host "" + Write-Host "Recommendations:" + Write-Host "1. Fix missing SKILL.md files" + Write-Host "2. Add required front matter fields" + Write-Host "3. Ensure Role and Task sections exist" + Write-Host "4. Align skill versions with global version" + exit 1 +} diff --git a/.agents/scripts/powershell/validate-versions.ps1 b/.agents/scripts/powershell/validate-versions.ps1 new file mode 100644 index 0000000..f20fdef --- /dev/null +++ b/.agents/scripts/powershell/validate-versions.ps1 @@ -0,0 +1,112 @@ +# validate-versions.ps1 - Check version consistency across .agents files +# Part of LCBP3-DMS Phase 2 improvements + +param( + [string]$BaseDir = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)), + [string]$ExpectedVersion = "1.8.6" +) + +# Colors for output +$Colors = @{ + Red = "`e[0;31m" + Green = "`e[0;32m" + Yellow = "`e[1;33m" + NoColor = "`e[0m" +} + +$AgentsDir = Join-Path $BaseDir ".agents" + +Write-Host "=== .agents Version Validation ===" -ForegroundColor Cyan +Write-Host "Base directory: $BaseDir" +Write-Host "Expected version: $ExpectedVersion" +Write-Host "" + +# Function to extract version from file +function Get-VersionFromFile { + param( + [string]$FilePath, + [string]$Pattern + ) + + if (Test-Path $FilePath) { + try { + $content = Get-Content $FilePath -Raw + if ($content -match $Pattern) { + return $matches[1] + } else { + return "NOT_FOUND" + } + } catch { + return "ERROR" + } + } else { + return "FILE_NOT_FOUND" + } +} + +# Files to check +$FilesToCheck = @{ + (Join-Path $AgentsDir "README.md") = "Version: ([0-9]+\.[0-9]+\.[0-9]+)" + (Join-Path $AgentsDir "skills\VERSION") = "version: ([0-9]+\.[0-9]+\.[0-9]+)" + (Join-Path $AgentsDir "rules\00-project-context.md") = "Version: ([0-9]+\.[0-9]+\.[0-9]+)" + (Join-Path $AgentsDir "skills\skills.md") = "V([0-9]+\.[0-9]+\.[0-9]+)" +} + +# Track issues +$Issues = 0 + +Write-Host "Checking version consistency..." +Write-Host "" + +foreach ($file in $FilesToCheck.Keys) { + $pattern = $FilesToCheck[$file] + $relativePath = $file.Replace($BaseDir + "\", "") + + $version = Get-VersionFromFile -FilePath $file -Pattern $pattern + + if ($version -eq "NOT_FOUND" -or $version -eq "FILE_NOT_FOUND") { + Write-Host " ERROR: $relativePath - Version not found" -ForegroundColor $Colors.Red + $Issues++ + } elseif ($version -ne $ExpectedVersion) { + Write-Host " ERROR: $relativePath - Found v$version, expected v$ExpectedVersion" -ForegroundColor $Colors.Red + $Issues++ + } else { + Write-Host " OK: $relativePath - v$version" -ForegroundColor $Colors.Green + } +} + +Write-Host "" + +# Check for version mismatches in skill files +Write-Host "Checking skill file versions..." +$SkillsVersionFile = Join-Path $AgentsDir "skills\VERSION" +if (Test-Path $SkillsVersionFile) { + $skillsVersion = Get-VersionFromFile -FilePath $SkillsVersionFile -Pattern "version: ([0-9]+\.[0-9]+\.[0-9]+)" + Write-Host "Skills version file: v$skillsVersion" +} + +# Check workflow versions (in .windsurf\workflows) +$WorkflowsDir = Join-Path $BaseDir ".windsurf\workflows" +if (Test-Path $WorkflowsDir) { + Write-Host "Checking workflow files..." + $workflowCount = (Get-ChildItem -Path $WorkflowsDir -Filter "*.md" -File | Measure-Object).Count + Write-Host " OK: Found $workflowCount workflow files" -ForegroundColor $Colors.Green +} else { + Write-Host " WARNING: Workflows directory not found at $WorkflowsDir" -ForegroundColor $Colors.Yellow +} + +Write-Host "" + +# Summary +if ($Issues -eq 0) { + Write-Host "=== SUCCESS: All versions consistent ===" -ForegroundColor $Colors.Green + exit 0 +} else { + Write-Host "=== FAILED: $Issues version issues found ===" -ForegroundColor $Colors.Red + Write-Host "" + Write-Host "To fix version issues:" + Write-Host "1. Update files to use v$ExpectedVersion" + Write-Host "2. Ensure LCBP3 project version matches" + Write-Host "3. Run this script again to verify" + exit 1 +} diff --git a/.agents/skills/VERSION b/.agents/skills/VERSION index 56a75b6..cfedcd2 100644 --- a/.agents/skills/VERSION +++ b/.agents/skills/VERSION @@ -1,10 +1,16 @@ # Speckit Skills Version -version: 1.1.0 -release_date: 2026-01-24 +version: 1.8.6 +release_date: 2026-04-14 ## Changelog +### 1.8.6 (2026-04-14) +- Version alignment with LCBP3-DMS v1.8.6 +- Complete skill implementations for all 20 skills +- Enhanced security and audit capabilities +- Production-ready deployment status + ### 1.1.0 (2026-01-24) - New QA skills: tester, reviewer, checker - tester: Execute tests, measure coverage, report results diff --git a/.agents/skills/skills.md b/.agents/skills/skills.md new file mode 100644 index 0000000..5a46fa5 --- /dev/null +++ b/.agents/skills/skills.md @@ -0,0 +1,105 @@ +# 🧠 NAP-DMS Agent Skills (v1.8.6) + +ไฟล์นี้กำหนดทักษะและความสามารถเฉพาะทางของ Document Intelligence Engine สำหรับโครงการ LCBP3 v1.8.6 เพื่อรักษามาตรฐานสูงสุดด้าน Security และ Data Integrity + +**Status**: Production Ready | **Last Updated**: 2026-04-14 | **Total Skills**: 20 + +--- + +## 🏗️ Architectural & Data Integrity + +- **Identifier Strategy Mastery (ADR-019):** + - บังคับใช้ **UUIDv7** เป็น Public ID ใน API และ URL เสมอ + - ตรวจสอบและป้องกันการใช้ `parseInt()`, `Number()`, หรือตัวดำเนินการทางคณิตศาสตร์ (`+`) กับ UUID + - ตรวจสอบว่า Entity มีการใช้ `@Exclude()` บน Primary Key ที่เป็น `INT AUTO_INCREMENT` เพื่อไม่ให้หลุดออกไปยัง API +- **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 | + +--- + +## 🛠️ Skill Health Monitoring + +### Health Check Scripts + +- **Bash**: `./scripts/bash/audit-skills.sh` - Comprehensive skill health audit +- **PowerShell**: `./scripts/powershell/audit-skills.ps1` - Windows equivalent + +### Validation Scripts + +- **Version Check**: `./scripts/bash/validate-versions.sh` - Ensure version consistency +- **Workflow Sync**: `./scripts/bash/sync-workflows.sh` - Verify workflow integration + +### Health Metrics + +- **Total Skills**: 20 implemented +- **Version Alignment**: v1.8.6 across all skills +- **Template Coverage**: 100% for skills requiring templates +- **Documentation**: Complete front matter and sections + +### 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 diff --git a/.agents/tests/skill-integration.test.js b/.agents/tests/skill-integration.test.js new file mode 100644 index 0000000..71c93e1 --- /dev/null +++ b/.agents/tests/skill-integration.test.js @@ -0,0 +1,241 @@ +/** + * skill-integration.test.js - Integration tests for .agents skills + * Part of LCBP3-DMS Phase 3 enhancements + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Test configuration +const BASE_DIR = path.resolve(__dirname, '..'); +const AGENTS_DIR = path.join(BASE_DIR, '.agents'); +const SKILLS_DIR = path.join(AGENTS_DIR, 'skills'); +const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows'); + +// Test utilities +class SkillTestSuite { + constructor() { + this.results = { + passed: 0, + failed: 0, + errors: [] + }; + } + + log(message, type = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m' + }; + + const color = colors[type] || colors.info; + console.log(`${color}${message}${colors.reset}`); + } + + assert(condition, message) { + if (condition) { + this.log(` PASS: ${message}`, 'pass'); + this.results.passed++; + return true; + } else { + this.log(` FAIL: ${message}`, 'fail'); + this.results.failed++; + this.results.errors.push(message); + return false; + } + } + + testDirectoryExists(dirPath, description) { + const exists = fs.existsSync(dirPath); + this.assert(exists, `${description} exists at ${dirPath}`); + return exists; + } + + testFileExists(filePath, description) { + const exists = fs.existsSync(filePath); + this.assert(exists, `${description} exists at ${filePath}`); + return exists; + } + + testFileContent(filePath, pattern, description) { + if (!fs.existsSync(filePath)) { + this.assert(false, `${description} - file not found: ${filePath}`); + return false; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + const matches = content.match(pattern); + this.assert(matches !== null, `${description} - pattern found in ${filePath}`); + return matches !== null; + } catch (error) { + this.assert(false, `${description} - error reading file: ${error.message}`); + return false; + } + } + + runScript(scriptPath, description) { + try { + const output = execSync(scriptPath, { encoding: 'utf8', cwd: BASE_DIR }); + this.log(` SCRIPT: ${description} executed successfully`, 'pass'); + return { success: true, output }; + } catch (error) { + this.log(` SCRIPT: ${description} failed - ${error.message}`, 'fail'); + this.results.failed++; + this.results.errors.push(`${description}: ${error.message}`); + return { success: false, error: error.message }; + } + } +} + +// Test suite implementation +const testSuite = new SkillTestSuite(); + +function runAllTests() { + testSuite.log('=== .agents Integration Test Suite ===', 'info'); + testSuite.log(`Base directory: ${BASE_DIR}`, 'info'); + testSuite.log(`Started: ${new Date().toISOString()}`, 'info'); + testSuite.log(''); + + // Test 1: Directory Structure + testSuite.log('Test 1: Directory Structure', 'info'); + testSuite.testDirectoryExists(AGENTS_DIR, '.agents directory'); + testSuite.testDirectoryExists(SKILLS_DIR, 'skills directory'); + testSuite.testDirectoryExists(WORKFLOWS_DIR, 'workflows directory'); + testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'scripts'), 'scripts directory'); + testSuite.testDirectoryExists(path.join(AGENTS_DIR, 'rules'), 'rules directory'); + testSuite.log(''); + + // Test 2: Core Files + testSuite.log('Test 2: Core Files', 'info'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'README.md'), 'README.md'); + testSuite.testFileExists(path.join(SKILLS_DIR, 'VERSION'), 'skills VERSION file'); + testSuite.testFileExists(path.join(SKILLS_DIR, 'skills.md'), 'skills.md documentation'); + testSuite.log(''); + + // Test 3: Script Files + testSuite.log('Test 3: Validation Scripts', 'info'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'), 'bash validate-versions.sh'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'audit-skills.sh'), 'bash audit-skills.sh'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'bash', 'sync-workflows.sh'), 'bash sync-workflows.sh'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'validate-versions.ps1'), 'powershell validate-versions.ps1'); + testSuite.testFileExists(path.join(AGENTS_DIR, 'scripts', 'powershell', 'audit-skills.ps1'), 'powershell audit-skills.ps1'); + testSuite.log(''); + + // Test 4: Version Consistency + testSuite.log('Test 4: Version Consistency', 'info'); + testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /v1\.8\.6/, 'README.md version'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'VERSION'), /version: 1\.8\.6/, 'skills VERSION file'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /v1\.8\.6/, 'skills.md version'); + testSuite.testFileContent(path.join(AGENTS_DIR, 'rules', '00-project-context.md'), /v1\.8\.6/, 'project context version'); + testSuite.log(''); + + // Test 5: Skills Structure + testSuite.log('Test 5: Skills Structure', 'info'); + const skillDirs = fs.readdirSync(SKILLS_DIR).filter(item => { + const itemPath = path.join(SKILLS_DIR, item); + return fs.statSync(itemPath).isDirectory() && item.startsWith('speckit-') || item === 'nestjs-best-practices' || item === 'next-best-practices'; + }); + + testSuite.assert(skillDirs.length >= 20, `Found at least 20 skill directories (found ${skillDirs.length})`); + + // Test a few key skills + const keySkills = ['speckit-plan', 'speckit-implement', 'speckit-specify', 'speckit-validate']; + keySkills.forEach(skill => { + const skillPath = path.join(SKILLS_DIR, skill); + const skillMdPath = path.join(skillPath, 'SKILL.md'); + testSuite.testDirectoryExists(skillPath, `${skill} directory`); + testSuite.testFileExists(skillMdPath, `${skill} SKILL.md`); + + if (fs.existsSync(skillMdPath)) { + testSuite.testFileContent(skillMdPath, /^name:/, `${skill} has name field`); + testSuite.testFileContent(skillMdPath, /^description:/, `${skill} has description field`); + testSuite.testFileContent(skillMdPath, /^version:/, `${skill} has version field`); + testSuite.testFileContent(skillMdPath, /^## Role$/, `${skill} has Role section`); + testSuite.testFileContent(skillMdPath, /^## Task$/, `${skill} has Task section`); + } + }); + testSuite.log(''); + + // Test 6: Workflows Structure + testSuite.log('Test 6: Workflows Structure', 'info'); + const workflowFiles = fs.readdirSync(WORKFLOWS_DIR).filter(item => item.endsWith('.md')); + testSuite.assert(workflowFiles.length >= 20, `Found at least 20 workflow files (found ${workflowFiles.length})`); + + // Test key workflows + const keyWorkflows = ['00-speckit.all.md', '02-speckit.specify.md', '04-speckit.plan.md', '07-speckit.implement.md']; + keyWorkflows.forEach(workflow => { + const workflowPath = path.join(WORKFLOWS_DIR, workflow); + testSuite.testFileExists(workflowPath, `${workflow} file`); + }); + testSuite.log(''); + + // Test 7: Rules Structure + testSuite.log('Test 7: Rules Structure', 'info'); + const rulesDir = path.join(AGENTS_DIR, 'rules'); + const ruleFiles = fs.readdirSync(rulesDir).filter(item => item.endsWith('.md')); + testSuite.assert(ruleFiles.length >= 10, `Found at least 10 rule files (found ${ruleFiles.length})`); + + // Test key rules + const keyRules = ['00-project-context.md', '01-adr-019-uuid.md', '02-security.md']; + keyRules.forEach(rule => { + const rulePath = path.join(rulesDir, rule); + testSuite.testFileExists(rulePath, `${rule} file`); + }); + testSuite.log(''); + + // Test 8: Script Execution (if on Unix-like system) + if (process.platform !== 'win32') { + testSuite.log('Test 8: Script Execution', 'info'); + + // Test version validation script + const versionScript = path.join(AGENTS_DIR, 'scripts', 'bash', 'validate-versions.sh'); + if (fs.existsSync(versionScript)) { + try { + // Make executable + fs.chmodSync(versionScript, '755'); + testSuite.runScript(versionScript, 'Version validation script'); + } catch (error) { + testSuite.log(` SKIP: Cannot execute version script - ${error.message}`, 'warn'); + } + } + + testSuite.log(''); + } + + // Test 9: Documentation Quality + testSuite.log('Test 9: Documentation Quality', 'info'); + testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /## Troubleshooting/, 'README.md has troubleshooting section'); + testSuite.testFileContent(path.join(SKILLS_DIR, 'skills.md'), /## Skill Dependency Matrix/, 'skills.md has dependency matrix'); + testSuite.testFileContent(path.join(AGENTS_DIR, 'README.md'), /## Architecture/, 'README.md has architecture section'); + testSuite.log(''); + + // Results Summary + testSuite.log('=== Test Results Summary ===', 'info'); + testSuite.log(`Passed: ${testSuite.results.passed}`, 'pass'); + testSuite.log(`Failed: ${testSuite.results.failed}`, testSuite.results.failed > 0 ? 'fail' : 'pass'); + + if (testSuite.results.errors.length > 0) { + testSuite.log('Errors:', 'fail'); + testSuite.results.errors.forEach(error => { + testSuite.log(` - ${error}`, 'fail'); + }); + } + + testSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); + + return testSuite.results.failed === 0; +} + +// Export for use in other modules +module.exports = { SkillTestSuite, runAllTests }; + +// Run tests if called directly +if (require.main === module) { + const success = runAllTests(); + process.exit(success ? 0 : 1); +} diff --git a/.agents/tests/workflow-validation.test.js b/.agents/tests/workflow-validation.test.js new file mode 100644 index 0000000..368911d --- /dev/null +++ b/.agents/tests/workflow-validation.test.js @@ -0,0 +1,235 @@ +/** + * workflow-validation.test.js - Integration tests for workflows + * Part of LCBP3-DMS Phase 3 enhancements + */ + +const fs = require('fs'); +const path = require('path'); + +// Test configuration +const BASE_DIR = path.resolve(__dirname, '..'); +const WORKFLOWS_DIR = path.join(BASE_DIR, '.windsurf', 'workflows'); +const AGENTS_DIR = path.join(BASE_DIR, '.agents'); + +// Test utilities +class WorkflowTestSuite { + constructor() { + this.results = { + passed: 0, + failed: 0, + errors: [] + }; + } + + log(message, type = 'info') { + const colors = { + info: '\x1b[36m', // Cyan + pass: '\x1b[32m', // Green + fail: '\x1b[31m', // Red + warn: '\x1b[33m', // Yellow + reset: '\x1b[0m' + }; + + const color = colors[type] || colors.info; + console.log(`${color}${message}${colors.reset}`); + } + + assert(condition, message) { + if (condition) { + this.log(` PASS: ${message}`, 'pass'); + this.results.passed++; + return true; + } else { + this.log(` FAIL: ${message}`, 'fail'); + this.results.failed++; + this.results.errors.push(message); + return false; + } + } + + testWorkflowFile(filePath, expectedName) { + if (!fs.existsSync(filePath)) { + this.assert(false, `Workflow file exists: ${expectedName}`); + return false; + } + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Basic structure checks + this.assert(content.length > 0, `${expectedName} has content`); + this.assert(content.includes('#'), `${expectedName} has markdown headers`); + + // Check for workflow-specific patterns + if (expectedName.includes('speckit-')) { + this.assert(content.includes('speckit-'), `${expectedName} contains speckit reference`); + } + + // Check for proper markdown formatting + const lines = content.split('\n'); + const nonEmptyLines = lines.filter(line => line.trim().length > 0); + this.assert(nonEmptyLines.length >= 5, `${expectedName} has sufficient content`); + + return true; + } catch (error) { + this.assert(false, `${expectedName} - error reading file: ${error.message}`); + return false; + } + } + + validateWorkflowDependency(workflowName, workflowContent) { + // Check if workflow references existing skills + const skillReferences = workflowContent.match(/@speckit-\w+/g) || []; + const skillsDir = path.join(AGENTS_DIR, 'skills'); + + for (const skillRef of skillReferences) { + const skillName = skillRef.replace('@', ''); + const skillPath = path.join(skillsDir, skillName); + + if (!fs.existsSync(skillPath)) { + this.assert(false, `${workflowName} references non-existent skill: ${skillRef}`); + return false; + } + } + + return true; + } +} + +// Expected workflows mapping +const expectedWorkflows = { + '00-speckit.all.md': 'Full pipeline workflow', + '01-speckit.constitution.md': 'Constitution workflow', + '02-speckit.specify.md': 'Specification workflow', + '03-speckit.clarify.md': 'Clarification workflow', + '04-speckit.plan.md': 'Planning workflow', + '05-speckit.tasks.md': 'Task breakdown workflow', + '06-speckit.analyze.md': 'Analysis workflow', + '07-speckit.implement.md': 'Implementation workflow', + '08-speckit.checker.md': 'Static analysis workflow', + '09-speckit.tester.md': 'Testing workflow', + '10-speckit.reviewer.md': 'Code review workflow', + '11-speckit.validate.md': 'Validation workflow', + 'speckit.prepare.md': 'Preparation workflow', + 'schema-change.md': 'Schema change workflow', + 'create-backend-module.md': 'Backend module creation', + 'create-frontend-page.md': 'Frontend page creation', + 'deploy.md': 'Deployment workflow', + 'review.md': 'Code review workflow', + 'util-speckit.checklist.md': 'Checklist utility', + 'util-speckit.diff.md': 'Diff utility', + 'util-speckit.migrate.md': 'Migration utility', + 'util-speckit.quizme.md': 'Quiz utility', + 'util-speckit.status.md': 'Status utility', + 'util-speckit.taskstoissues.md': 'Task to issues utility' +}; + +// Test suite implementation +const workflowTestSuite = new WorkflowTestSuite(); + +function runWorkflowTests() { + workflowTestSuite.log('=== Workflow Validation Test Suite ===', 'info'); + workflowTestSuite.log(`Workflows directory: ${WORKFLOWS_DIR}`, 'info'); + workflowTestSuite.log(`Started: ${new Date().toISOString()}`, 'info'); + workflowTestSuite.log(''); + + // Test 1: Workflows directory exists + workflowTestSuite.log('Test 1: Directory Structure', 'info'); + workflowTestSuite.assert(fs.existsSync(WORKFLOWS_DIR), 'Workflows directory exists'); + workflowTestSuite.log(''); + + // Test 2: Expected workflow files exist + workflowTestSuite.log('Test 2: Expected Workflow Files', 'info'); + let foundWorkflows = 0; + + for (const [filename, description] of Object.entries(expectedWorkflows)) { + const filePath = path.join(WORKFLOWS_DIR, filename); + workflowTestSuite.testWorkflowFile(filePath, description); + if (fs.existsSync(filePath)) { + foundWorkflows++; + } + } + + workflowTestSuite.assert(foundWorkflows >= 20, `Found at least 20 workflows (found ${foundWorkflows})`); + workflowTestSuite.log(''); + + // Test 3: Workflow content validation + workflowTestSuite.log('Test 3: Content Validation', 'info'); + + for (const [filename, description] of Object.entries(expectedWorkflows)) { + const filePath = path.join(WORKFLOWS_DIR, filename); + + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Check for proper workflow structure + workflowTestSuite.assert(content.includes('#'), `${filename} has markdown headers`); + workflowTestSuite.assert(content.length > 100, `${filename} has substantial content`); + + // Validate skill dependencies + workflowTestSuite.validateWorkflowDependency(filename, content); + + } catch (error) { + workflowTestSuite.assert(false, `${filename} - content validation error: ${error.message}`); + } + } + } + workflowTestSuite.log(''); + + // Test 4: Workflow naming consistency + workflowTestSuite.log('Test 4: Naming Consistency', 'info'); + const actualFiles = fs.readdirSync(WORKFLOWS_DIR).filter(file => file.endsWith('.md')); + + for (const actualFile of actualFiles) { + if (!expectedWorkflows[actualFile]) { + workflowTestSuite.log(` UNEXPECTED: ${actualFile} not in expected list`, 'warn'); + } + } + + for (const expectedFile of Object.keys(expectedWorkflows)) { + if (!actualFiles.includes(expectedFile)) { + workflowTestSuite.assert(false, `Missing expected workflow: ${expectedFile}`); + } + } + workflowTestSuite.log(''); + + // Test 5: Cross-reference validation + workflowTestSuite.log('Test 5: Cross-Reference Validation', 'info'); + + // Check if README.md references workflows correctly + const readmePath = path.join(AGENTS_DIR, 'README.md'); + if (fs.existsSync(readmePath)) { + const readmeContent = fs.readFileSync(readmePath, 'utf8'); + workflowTestSuite.assert( + readmeContent.includes('.windsurf/workflows'), + 'README.md references correct workflows path' + ); + } + workflowTestSuite.log(''); + + // Results Summary + workflowTestSuite.log('=== Workflow Test Results Summary ===', 'info'); + workflowTestSuite.log(`Passed: ${workflowTestSuite.results.passed}`, 'pass'); + workflowTestSuite.log(`Failed: ${workflowTestSuite.results.failed}`, workflowTestSuite.results.failed > 0 ? 'fail' : 'pass'); + + if (workflowTestSuite.results.errors.length > 0) { + workflowTestSuite.log('Errors:', 'fail'); + workflowTestSuite.results.errors.forEach(error => { + workflowTestSuite.log(` - ${error}`, 'fail'); + }); + } + + workflowTestSuite.log(`Completed: ${new Date().toISOString()}`, 'info'); + + return workflowTestSuite.results.failed === 0; +} + +// Export for use in other modules +module.exports = { WorkflowTestSuite, runWorkflowTests }; + +// Run tests if called directly +if (require.main === module) { + const success = runWorkflowTests(); + process.exit(success ? 0 : 1); +} diff --git a/.agents/workflows/00-speckit-all.md b/.agents/workflows/00-speckit-all.md deleted file mode 100644 index 48951d6..0000000 --- a/.agents/workflows/00-speckit-all.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -description: Run the full speckit pipeline from specification to analysis in one command. ---- - -# Workflow: speckit-all - -This meta-workflow orchestrates the **complete development lifecycle**, from specification through implementation and validation. For the preparation-only pipeline (steps 1-5), use `/speckit-prepare` instead. - -## Preparation Phase (Steps 1-5) - -1. **Specify** (`/speckit-specify`): - - Use the `view_file` tool to read: `.agents/skills/speckit-specify/SKILL.md` - - Execute with user's feature description - - Creates: `spec.md` - -2. **Clarify** (`/speckit-clarify`): - - Use the `view_file` tool to read: `.agents/skills/speckit-clarify/SKILL.md` - - Execute to resolve ambiguities - - Updates: `spec.md` - -3. **Plan** (`/speckit-plan`): - - Use the `view_file` tool to read: `.agents/skills/speckit-plan/SKILL.md` - - Execute to create technical design - - Creates: `plan.md` - -4. **Tasks** (`/speckit-tasks`): - - Use the `view_file` tool to read: `.agents/skills/speckit-tasks/SKILL.md` - - Execute to generate task breakdown - - Creates: `tasks.md` - -5. **Analyze** (`/speckit-analyze`): - - Use the `view_file` tool to read: `.agents/skills/speckit-analyze/SKILL.md` - - Execute to validate consistency across spec, plan, and tasks - - Output: Analysis report - - **Gate**: If critical issues found, stop and fix before proceeding - -## Implementation Phase (Steps 6-7) - -6. **Implement** (`/speckit-implement`): - - Use the `view_file` tool to read: `.agents/skills/speckit-implement/SKILL.md` - - Execute all tasks from `tasks.md` with anti-regression protocols - - Output: Working implementation - -7. **Check** (`/speckit-checker`): - - Use the `view_file` tool to read: `.agents/skills/speckit-checker/SKILL.md` - - Run static analysis (linters, type checkers, security scanners) - - Output: Checker report - -## Verification Phase (Steps 8-10) - -8. **Test** (`/speckit-tester`): - - Use the `view_file` tool to read: `.agents/skills/speckit-tester/SKILL.md` - - Run tests with coverage - - Output: Test + coverage report - -9. **Review** (`/speckit-reviewer`): - - Use the `view_file` tool to read: `.agents/skills/speckit-reviewer/SKILL.md` - - Perform code review - - Output: Review report with findings - -10. **Validate** (`/speckit-validate`): - - Use the `view_file` tool to read: `.agents/skills/speckit-validate/SKILL.md` - - Verify implementation matches spec requirements - - Output: Validation report (pass/fail) - -## Usage - -``` -/speckit-all "Build a user authentication system with OAuth2 support" -``` - -## Pipeline Comparison - -| Pipeline | Steps | Use When | -| ------------------ | ------------------------- | -------------------------------------- | -| `/speckit-prepare` | 1-5 (Specify → Analyze) | Planning only — you'll implement later | -| `/speckit-all` | 1-10 (Specify → Validate) | Full lifecycle in one pass | - -## On Error - -If any step fails, stop the pipeline and report: - -- Which step failed -- The error message -- Suggested remediation (e.g., "Run `/speckit-clarify` to resolve ambiguities before continuing") diff --git a/.agents/workflows/01-speckit-constitution.md b/.agents/workflows/01-speckit-constitution.md deleted file mode 100644 index 506a6d2..0000000 --- a/.agents/workflows/01-speckit-constitution.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. ---- - -# Workflow: speckit-constitution - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-constitution/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `.specify/` directory doesn't exist: Initialize the speckit structure first diff --git a/.agents/workflows/02-speckit-specify.md b/.agents/workflows/02-speckit-specify.md deleted file mode 100644 index 229be3e..0000000 --- a/.agents/workflows/02-speckit-specify.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Create or update the feature specification from a natural language feature description. ---- - -# Workflow: speckit-specify - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - - This is typically the starting point of a new feature. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-specify/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the feature description for the skill's logic. - -4. **On Error**: - - If no feature description provided: Ask the user to describe the feature they want to specify diff --git a/.agents/workflows/03-speckit-clarify.md b/.agents/workflows/03-speckit-clarify.md deleted file mode 100644 index 83e377d..0000000 --- a/.agents/workflows/03-speckit-clarify.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -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. ---- - -# Workflow: speckit-clarify - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-clarify/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit-specify` first to create the feature specification diff --git a/.agents/workflows/04-speckit-plan.md b/.agents/workflows/04-speckit-plan.md deleted file mode 100644 index 7344e25..0000000 --- a/.agents/workflows/04-speckit-plan.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Execute the implementation planning workflow using the plan template to generate design artifacts. ---- - -# Workflow: speckit-plan - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-plan/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit-specify` first to create the feature specification diff --git a/.agents/workflows/05-speckit-tasks.md b/.agents/workflows/05-speckit-tasks.md deleted file mode 100644 index b9f11fc..0000000 --- a/.agents/workflows/05-speckit-tasks.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. ---- - -# Workflow: speckit-tasks - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-tasks/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `plan.md` is missing: Run `/speckit-plan` first - - If `spec.md` is missing: Run `/speckit-specify` first diff --git a/.agents/workflows/06-speckit-analyze.md b/.agents/workflows/06-speckit-analyze.md deleted file mode 100644 index 5440d6a..0000000 --- a/.agents/workflows/06-speckit-analyze.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. ---- - -// turbo-all - -# Workflow: speckit-analyze - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-analyze/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit-specify` first - - If `plan.md` is missing: Run `/speckit-plan` first - - If `tasks.md` is missing: Run `/speckit-tasks` first diff --git a/.agents/workflows/07-speckit-implement.md b/.agents/workflows/07-speckit-implement.md deleted file mode 100644 index 3b96e46..0000000 --- a/.agents/workflows/07-speckit-implement.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Execute the implementation plan by processing and executing all tasks defined in tasks.md ---- - -# Workflow: speckit-implement - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-implement/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit-tasks` first - - If `plan.md` is missing: Run `/speckit-plan` first - - If `spec.md` is missing: Run `/speckit-specify` first diff --git a/.agents/workflows/08-speckit-checker.md b/.agents/workflows/08-speckit-checker.md deleted file mode 100644 index a81b4d0..0000000 --- a/.agents/workflows/08-speckit-checker.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Run static analysis tools and aggregate results. ---- - -// turbo-all - -# Workflow: speckit-checker - -1. **Context Analysis**: - - The user may specify paths to check or run on entire project. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-checker/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no linting tools available: Report which tools to install based on project type - - If tools fail: Show raw error and suggest config fixes diff --git a/.agents/workflows/09-speckit-tester.md b/.agents/workflows/09-speckit-tester.md deleted file mode 100644 index e7dd12b..0000000 --- a/.agents/workflows/09-speckit-tester.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Execute tests, measure coverage, and report results. ---- - -// turbo-all - -# Workflow: speckit-tester - -1. **Context Analysis**: - - The user may specify test paths, options, or just run all tests. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-tester/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no test framework detected: Report "No test framework found. Install Jest, Vitest, Pytest, or similar." - - If tests fail: Show failure details and suggest fixes diff --git a/.agents/workflows/10-speckit-reviewer.md b/.agents/workflows/10-speckit-reviewer.md deleted file mode 100644 index b2866e0..0000000 --- a/.agents/workflows/10-speckit-reviewer.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Perform code review with actionable feedback and suggestions. ---- - -# Workflow: speckit-reviewer - -1. **Context Analysis**: - - The user may specify files to review, "staged" for git staged changes, or "branch" for branch diff. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-reviewer/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no files to review: Ask user to stage changes or specify file paths - - If not a git repo: Review current directory files instead diff --git a/.agents/workflows/11-speckit-validate.md b/.agents/workflows/11-speckit-validate.md deleted file mode 100644 index 5b40fc5..0000000 --- a/.agents/workflows/11-speckit-validate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Validate that implementation matches specification requirements. ---- - -# Workflow: speckit-validate - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-validate/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit-tasks` first - - If implementation not started: Run `/speckit-implement` first diff --git a/.agents/workflows/create-backend-module.md b/.agents/workflows/create-backend-module.md deleted file mode 100644 index 78cf13d..0000000 --- a/.agents/workflows/create-backend-module.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -description: Create a new NestJS backend feature module following project standards ---- - -# Create NestJS Backend Module - -Use this workflow when creating a new feature module in `backend/src/modules/`. -Follows `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` and ADR-005. - -## Steps - -// turbo - -1. **Verify requirements exist** — confirm the feature is in `specs/01-Requirements/` before starting - -// turbo 2. **Check schema** — read `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql` for relevant tables - -3. **Scaffold module folder** - -``` -backend/src/modules// -├── .module.ts -├── .controller.ts -├── .service.ts -├── dto/ -│ ├── create-.dto.ts -│ └── update-.dto.ts -├── entities/ -│ └── .entity.ts -└── .controller.spec.ts -``` - -4. **Create Entity** — map ONLY columns defined in the schema SQL. Use TypeORM decorators. Add `@VersionColumn()` if the entity needs optimistic locking. - -5. **Create DTOs** — use `class-validator` decorators. Never use `any`. Validate all inputs. - -6. **Create Service** — inject repository via constructor DI. Use transactions for multi-step writes. Add `Idempotency-Key` guard for POST/PUT/PATCH operations. - -7. **Create Controller** — apply `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`. Use proper HTTP status codes. Document with `@ApiTags` and `@ApiOperation`. - -8. **Register in Module** — add to `imports`, `providers`, `controllers`, `exports` as needed. - -9. **Register in AppModule** — import the new module in `app.module.ts`. - -// turbo 10. **Write unit test** — cover service methods with Jest mocks. Run: - -```bash -pnpm test:watch -``` - -// turbo 11. **Citation** — confirm implementation references `specs/01-Requirements/` and `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` diff --git a/.agents/workflows/create-frontend-page.md b/.agents/workflows/create-frontend-page.md deleted file mode 100644 index 22c4b2e..0000000 --- a/.agents/workflows/create-frontend-page.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -description: Create a new Next.js App Router page following project standards ---- - -# Create Next.js Frontend Page - -Use this workflow when creating a new page in `frontend/app/`. -Follows `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md`, ADR-011, ADR-012, ADR-013, ADR-014. - -## Steps - -1. **Determine route** — decide the route path, e.g. `app/(dashboard)/documents/page.tsx` - -2. **Classify components** — decide what is Server Component (default) vs Client Component (`'use client'`) - - Server Component: initial data load, static content, SEO - - Client Component: interactivity, forms, TanStack Query hooks, Zustand - -3. **Create page file** — Server Component by default: - -```typescript -// app/(dashboard)//page.tsx -import { Metadata } from 'next'; - -export const metadata: Metadata = { - title: ' | LCBP3-DMS', -}; - -export default async function Page() { - return ( -
- {/* Page content */} -
- ); -} -``` - -4. **Create API hook** (if client-side data needed) — add to `hooks/use-.ts`: - -```typescript -'use client'; -import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '@/lib/api-client'; - -export function use() { - return useQuery({ - queryKey: [''], - queryFn: () => apiClient.get(''), - }); -} -``` - -5. **Build UI components** — use Shadcn/UI primitives. Place reusable components in `components//`. - -6. **Handle forms** — use React Hook Form + Zod schema validation. Never access form values without validation. - -7. **Handle errors** — add `error.tsx` alongside `page.tsx` for route-level error boundaries. - -8. **Add loading state** — add `loading.tsx` for Suspense fallback if page does async work. - -9. **Add to navigation** — update sidebar/nav config if the page should appear in the menu. - -10. **Access control** — ensure page checks CASL permissions. Redirect unauthorized users via middleware or `notFound()`. - -11. **Citation** — confirm implementation references `specs/01-Requirements/` and `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` diff --git a/.agents/workflows/deploy.md b/.agents/workflows/deploy.md deleted file mode 100644 index 4067162..0000000 --- a/.agents/workflows/deploy.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: Deploy the application via Gitea Actions to QNAP Container Station ---- - -# Deploy to Production - -Use this workflow to deploy updated backend and/or frontend to QNAP via Gitea Actions CI/CD. -Follows `specs/04-Infrastructure-OPS/` and ADR-015. - -## Pre-deployment Checklist - -- [ ] All tests pass locally (`pnpm test:watch`) -- [ ] No TypeScript errors (`tsc --noEmit`) -- [ ] No `any` types introduced -- [ ] Schema changes applied to `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql` -- [ ] Environment variables documented (NOT in `.env` files) - -## Steps - -1. **Commit and push to Gitea** - -```bash -git status -git add . -git commit -m "feat(): " -git push origin main -``` - -2. **Monitor Gitea Actions** — open Gitea web UI → Actions tab → verify pipeline starts - -3. **Pipeline stages (automatic)** - - `build-backend` → Docker image build + push to registry - - `build-frontend` → Docker image build + push to registry - - `deploy` → SSH to QNAP → `docker compose pull` + `docker compose up -d` - -4. **Verify backend health** - -```bash -curl http://:3000/health -# Expected: { "status": "ok" } -``` - -5. **Verify frontend** - -```bash -curl -I http://:3001 -# Expected: HTTP 200 -``` - -6. **Check logs in Grafana** — navigate to Grafana → Loki → filter by container name - - Backend: `container_name="lcbp3-backend"` - - Frontend: `container_name="lcbp3-frontend"` - -7. **Verify database** — confirm schema changes are reflected (if any) - -8. **Rollback (if needed)** - -```bash -# SSH into QNAP -docker compose pull = -docker compose up -d -``` - -## Common Issues - -| Symptom | Cause | Fix | -| ----------------- | --------------------- | ----------------------------------- | -| Backend unhealthy | DB connection failed | Check MariaDB container + env vars | -| Frontend blank | Build error | Check Next.js build logs in Grafana | -| 502 Bad Gateway | Container not started | `docker compose ps` to check status | -| Pipeline stuck | Gitea runner offline | Restart runner on QNAP | diff --git a/.agents/workflows/review.md b/.agents/workflows/review.md deleted file mode 100644 index 37fc514..0000000 --- a/.agents/workflows/review.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -auto_execution_mode: 0 -description: Review code changes for bugs, security issues, and improvements ---- - -You are a senior software engineer performing a thorough code review to identify potential bugs. - -Your task is to find all potential bugs and code improvements in the code changes. Focus on: - -1. Logic errors and incorrect behavior -2. Edge cases that aren't handled -3. Null/undefined reference issues -4. Race conditions or concurrency issues -5. Security vulnerabilities -6. Improper resource management or resource leaks -7. API contract violations -8. Incorrect caching behavior, including cache staleness issues, cache key-related bugs, incorrect cache invalidation, and ineffective caching -9. Violations of existing code patterns or conventions - -## 🔴 Tier 1 Critical Rules (CI Blockers) - -The following are **CI-blocking issues** that must be caught in code review. These align with project specs in `specs/05-Engineering-Guidelines/` and `specs/06-Decision-Records/`: - -### ADR-019: UUID Handling - -- **❌ NEVER use `parseInt()`, `Number()`, or `+` operator on UUID values** - - Example of violation: `parseInt(projectId)` where `projectId` is UUID string - - ✅ Correct: Use UUID string directly without conversion -- **❌ NEVER expose internal INT PK in API responses** - - API must expose only `publicId` (transformed to `id` via `@Expose()`) - - Verify DTOs have `@Exclude()` on `id: number` field - -### TypeScript Strict Rules - -- **❌ ZERO `any` types allowed** — use proper types or `unknown` + narrowing -- **❌ ZERO `console.log`** — must use NestJS `Logger` (backend) or remove (frontend) -- **❌ NO `req: any` in controllers** — use `RequestWithUser` typed interface - -### Database & Architecture - -- **❌ NO SQL Triggers for business logic** — use NestJS Service methods instead -- **❌ NO `.env` files in production** — use Docker environment variables -- **❌ NO direct table/column name invention** — verify against `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` - -### Security (ADR-016) - -- Idempotency validation for critical `POST`/`PUT`/`PATCH` endpoints -- Two-phase file upload pattern (Upload → Temp → Commit → Permanent) -- Input validation with class-validator (backend) and Zod (frontend) - -### Test Coverage Requirements - -- **Backend Services:** 80% minimum -- **Backend Overall:** 70% minimum -- **Business Logic:** 80% minimum - -Make sure to: - -1. If exploring the codebase, call multiple tools in parallel for increased efficiency. Do not spend too much time exploring. -2. If you find any pre-existing bugs in the code, you should also report those since it's important for us to maintain general code quality for the user. -3. Do NOT report issues that are speculative or low-confidence. All your conclusions should be based on a complete understanding of the codebase. -4. Remember that if you were given a specific git commit, it may not be checked out and local code states may be different. diff --git a/.agents/workflows/schema-change.md b/.agents/workflows/schema-change.md deleted file mode 100644 index ef5afb2..0000000 --- a/.agents/workflows/schema-change.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -description: Manage database schema changes following ADR-009 (no migrations, modify SQL directly) ---- - -# Schema Change Workflow - -Use this workflow when modifying database schema for LCBP3-DMS. -Follows `specs/06-Decision-Records/ADR-009-database-strategy.md` — **NO TypeORM migrations**. - -## Pre-Change Checklist - -- [ ] Change is required by a spec in `specs/01-Requirements/` -- [ ] Existing data impact has been assessed -- [ ] No SQL triggers are being added (business logic in NestJS only) - -## Steps - -1. **Read current schema** — load the full schema file: - -``` -specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql -``` - -2. **Read data dictionary** — understand current field definitions: - -``` -specs/03-Data-and-Storage/03-01-data-dictionary.md -``` - -// turbo 3. **Identify impact scope** — determine which tables, columns, indexes, or constraints are affected. List: - -- Tables being modified/created -- Columns being added/renamed/dropped -- Foreign key relationships affected -- Indexes being added/modified -- Seed data impact (if any) - -4. **Modify schema SQL** — edit `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql`: - - Add/modify table definitions - - Maintain consistent formatting (uppercase SQL keywords, lowercase identifiers) - - Add inline comments for new columns explaining purpose - - Ensure `DEFAULT` values and `NOT NULL` constraints are correct - - Add `version` column with `@VersionColumn()` marker comment if optimistic locking is needed - -> [!CAUTION] -> **NEVER use SQL Triggers.** All business logic must live in NestJS services. - -5. **Update data dictionary** — edit `specs/03-Data-and-Storage/03-01-data-dictionary.md`: - - Add new tables/columns with descriptions - - Update data types and constraints - - Document business rules for new fields - - Add enum value definitions if applicable - -6. **Update seed data** (if applicable): - - `specs/03-Data-and-Storage/lcbp3-v1.7.0-seed-basic.sql` — for reference/lookup data - - `specs/03-Data-and-Storage/lcbp3-v1.7.0-seed-permissions.sql` — for new CASL permissions - -7. **Update TypeORM entity** — modify corresponding `backend/src/modules//entities/*.entity.ts`: - - Map ONLY columns defined in schema SQL - - Use correct TypeORM decorators (`@Column`, `@PrimaryGeneratedColumn`, `@ManyToOne`, etc.) - - Add `@VersionColumn()` if optimistic locking is needed - -8. **Update DTOs** — if new columns are exposed via API: - - Add fields to `create-*.dto.ts` and/or `update-*.dto.ts` - - Add `class-validator` decorators for all new fields - - Never use `any` type - -// turbo 9. **Run type check** — verify no TypeScript errors: - -```bash -cd backend && npx tsc --noEmit -``` - -10. **Generate SQL diff** — create a summary of changes for the user to apply manually: - -``` --- Schema Change Summary --- Date: --- Feature: --- Tables affected: --- --- ⚠️ Apply this SQL to the live database manually: - -ALTER TABLE ...; --- or -CREATE TABLE ...; -``` - -11. **Notify user** — present the SQL diff and remind them: - - Apply the SQL change to the live database manually - - Verify the change doesn't break existing data - - Run `pnpm test` after applying to confirm entity mappings work - -## Common Patterns - -| Change Type | Template | -| ----------- | -------------------------------------------------------------- | -| Add column | `ALTER TABLE \`table\` ADD COLUMN \`col\` TYPE DEFAULT value;` | -| Add table | Full `CREATE TABLE` with constraints and indexes | -| Add index | `CREATE INDEX \`idx_table_col\` ON \`table\` (\`col\`);` | -| Add FK | `ALTER TABLE \`child\` ADD CONSTRAINT ... FOREIGN KEY ...` | -| Add enum | Add to data dictionary + `ENUM('val1','val2')` in column def | - -## On Error - -- If schema SQL has syntax errors → fix and re-validate with `tsc --noEmit` -- If entity mapping doesn't match schema → compare column-by-column against SQL -- If seed data conflicts → check unique constraints and foreign keys diff --git a/.agents/workflows/speckit-prepare.md b/.agents/workflows/speckit-prepare.md deleted file mode 100644 index 774c616..0000000 --- a/.agents/workflows/speckit-prepare.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: Execute the full preparation pipeline (Specify -> Clarify -> Plan -> Tasks -> Analyze) in sequence. ---- - -# Workflow: speckit-prepare - -This workflow orchestrates the sequential execution of the Speckit preparation phase skills (02-06). - -1. **Step 1: Specify (Skill 02)** - - Goal: Create or update the `spec.md` based on user input. - - Action: Read and execute `.agents/skills/speckit-specify/SKILL.md`. - -2. **Step 2: Clarify (Skill 03)** - - Goal: Refine the `spec.md` by identifying and resolving ambiguities. - - Action: Read and execute `.agents/skills/speckit-clarify/SKILL.md`. - -3. **Step 3: Plan (Skill 04)** - - Goal: Generate `plan.md` from the finalized spec. - - Action: Read and execute `.agents/skills/speckit-plan/SKILL.md`. - -4. **Step 4: Tasks (Skill 05)** - - Goal: Generate actionable `tasks.md` from the plan. - - Action: Read and execute `.agents/skills/speckit-tasks/SKILL.md`. - -5. **Step 5: Analyze (Skill 06)** - - Goal: Validate consistency across all design artifacts (spec, plan, tasks). - - Action: Read and execute `.agents/skills/speckit-analyze/SKILL.md`. diff --git a/.agents/workflows/util-speckit-checklist.md b/.agents/workflows/util-speckit-checklist.md deleted file mode 100644 index 8e12c3d..0000000 --- a/.agents/workflows/util-speckit-checklist.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Generate a custom checklist for the current feature based on user requirements. ---- - -# Workflow: speckit-checklist - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-checklist/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `spec.md` is missing: Run `/speckit-specify` first to create the feature specification diff --git a/.agents/workflows/util-speckit-diff.md b/.agents/workflows/util-speckit-diff.md deleted file mode 100644 index 5836bc1..0000000 --- a/.agents/workflows/util-speckit-diff.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Compare two versions of a spec or plan to highlight changes. ---- - -# Workflow: speckit-diff - -1. **Context Analysis**: - - The user has provided an input prompt (optional file paths or version references). - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-diff/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no files to compare: Use current feature's `spec.md` vs git HEAD - - If `spec.md` doesn't exist: Run `/speckit-specify` first diff --git a/.agents/workflows/util-speckit-migrate.md b/.agents/workflows/util-speckit-migrate.md deleted file mode 100644 index 61ab7e4..0000000 --- a/.agents/workflows/util-speckit-migrate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -description: Migrate existing projects into the speckit structure by generating spec.md, plan.md, and tasks.md from existing code. ---- - -# Workflow: speckit-migrate - -1. **Context Analysis**: - - The user has provided an input prompt (path to analyze, feature name). - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-migrate/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If path doesn't exist: Ask user to provide valid directory path - - If no code found: Report that no analyzable code was detected diff --git a/.agents/workflows/util-speckit-quizme.md b/.agents/workflows/util-speckit-quizme.md deleted file mode 100644 index 745d06e..0000000 --- a/.agents/workflows/util-speckit-quizme.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Challenge the specification with Socratic questioning to identify logical gaps, unhandled edge cases, and robustness issues. ---- - -// turbo-all - -# Workflow: speckit-quizme - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-quizme/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If required files don't exist, inform the user which prerequisite workflow to run first (e.g., `/speckit-specify` to create `spec.md`). diff --git a/.agents/workflows/util-speckit-status.md b/.agents/workflows/util-speckit-status.md deleted file mode 100644 index c467201..0000000 --- a/.agents/workflows/util-speckit-status.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Display a dashboard showing feature status, completion percentage, and blockers. ---- - -// turbo-all - -# Workflow: speckit-status - -1. **Context Analysis**: - - The user may optionally specify a feature to focus on. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-status/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If no features exist: Report "No features found. Run `/speckit-specify` to create your first feature." diff --git a/.agents/workflows/util-speckit-taskstoissues.md b/.agents/workflows/util-speckit-taskstoissues.md deleted file mode 100644 index 1231998..0000000 --- a/.agents/workflows/util-speckit-taskstoissues.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. ---- - -# Workflow: speckit-taskstoissues - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit-taskstoissues/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit-tasks` first diff --git a/.husky/pre-commit b/.husky/pre-commit index c9a5794..1630a2b 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -6,7 +6,7 @@ pnpm lint-staged # 2. Additional Global Safety Checks (Per t2.md) - Optimized for staged files # Use || true to prevent script exit if grep finds nothing for the file list -staged_files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$') || true +staged_files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ts|tsx|js|jsx)$' | grep -E '^(backend|frontend)/') || true if [ -n "$staged_files" ]; then # UUID misuse check diff --git a/.windsurf/rules/00-project-context.md b/.windsurf/rules/00-project-context.md deleted file mode 100644 index f639ba3..0000000 --- a/.windsurf/rules/00-project-context.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -trigger: always_on ---- - -# NAP-DMS Project Context - -## Role & Persona - -Act as a **Senior Full Stack Developer** specialized in: - -- NestJS, Next.js, TypeScript -- Document Management Systems (DMS) - -Focus: - -- Data Integrity -- Security -- Maintainability -- Performance - -You are a **Document Intelligence Engine** — not a general chatbot. -Every response must be **precise**, **spec-compliant**, and **production-ready**. - -## Project Information - -- **Project:** NAP-DMS (LCBP3) -- **Version:** 1.8.5 -- **Stack:** NestJS + Next.js + TypeScript + MariaDB + Ollama (AI) -- **Repo:** https://git.np-dms.work/np-dms/lcbp3 - -## Rule Enforcement Tiers - -### 🔴 Tier 1 — CRITICAL (CI BLOCKER) - -Build fails immediately if violated: - -- Security (Auth, RBAC, Validation) -- UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID -- Database correctness — verify schema before writing queries -- File upload security (ClamAV + whitelist) -- AI validation boundary (ADR-018) -- Error handling strategy (ADR-007) -- Forbidden patterns: `any`, `console.log`, UUID misuse - -### 🟡 Tier 2 — IMPORTANT (CODE REVIEW) - -Must fix before merge: - -- Architecture patterns (thin controller, business logic in service) -- Test coverage (80%+ business logic, 70%+ backend overall) -- Cache invalidation -- Naming conventions - -### 🟢 Tier 3 — GUIDELINES - -Best practice — follow when possible: - -- Code style / formatting (Prettier handles) -- Comment completeness -- Minor optimizations diff --git a/.windsurf/rules/01-adr-019-uuid.md b/.windsurf/rules/01-adr-019-uuid.md deleted file mode 100644 index d3a705a..0000000 --- a/.windsurf/rules/01-adr-019-uuid.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -trigger: always_on ---- - -# ADR-019 UUID Strategy - -## CRITICAL RULES - -- **NEVER** use `parseInt()` on UUID values -- **NEVER** use `Number()` on UUID values -- **NEVER** use `+` operator on UUID values -- **ALWAYS** use `publicId` (string UUID) for API responses -- **NEVER** expose internal INT `id` in API responses (use `@Exclude()`) - -## Identifier Types - -| Context | Type | Notes | -| ---------------- | ------------------------- | ------------------------------------------- | -| Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API | -| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed | -| Entity Property | `publicId: string` | Exposed directly in API (no transformation) | -| API Response | `publicId: string` (UUID) | INT `id` has `@Exclude()` — never appears | - -## Backend Pattern (NestJS/TypeORM) - -```typescript -// Entity -@Entity() -class Project extends UuidBaseEntity { - @Column({ type: 'uuid' }) - publicId: string; // UUID string, no transformation needed - - @PrimaryKey() - @Exclude() - id: number; // Internal INT, never exposed -} - -// API Response → { id: "019505a1-7c3e-7000-8000-abc123def456" } -// Uses publicId directly, no @Expose({ name: 'id' }) needed -``` - -## Frontend Pattern (Next.js) - -```typescript -// ✅ CORRECT — Use publicId only -type ProjectOption = { - publicId?: string; // No uuid, no id fallback - projectName?: string; -}; - -// ❌ WRONG — Multiple identifiers cause confusion -type ProjectOption = { - publicId?: string; - uuid?: string; // Don't do this - id?: number; // Don't do this -}; - -// ❌ NEVER use parseInt on UUID -parseInt(projectId); // "0195..." → 19 (WRONG!) - -// ❌ NEVER use id ?? '' fallback -const value = c.publicId ?? c.id ?? ''; // Wrong! - -// ✅ CORRECT — Use publicId only -const value = c.publicId; // "019505a1-7c3e-7000-8000-abc123def456" -``` - -## Related Documents - -- `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` -- `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md` diff --git a/.windsurf/rules/02-security.md b/.windsurf/rules/02-security.md deleted file mode 100644 index b7f5fa2..0000000 --- a/.windsurf/rules/02-security.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -trigger: always_on ---- - -# Security Rules (Non-Negotiable) - -## Mandatory Security Requirements - -1. **Idempotency:** All critical `POST`/`PUT`/`PATCH` MUST validate `Idempotency-Key` header -2. **Two-Phase File Upload:** Upload → Temp → Commit → Permanent -3. **Race Conditions:** Redis Redlock + TypeORM `@VersionColumn` for Document Numbering -4. **Validation:** Zod (frontend) + class-validator (backend DTO) -5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days -6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints -7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan -8. **AI Isolation (ADR-018):** Ollama on Admin Desktop ONLY — NO direct DB/storage access -9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages -10. **AI Integration (ADR-020):** RFA-First approach with unified pipeline architecture -11. **AI Audit Trail:** Log all AI interactions and human validations -12. **Rate Limiting:** Apply to AI endpoints to prevent abuse - -## Full Documentation - -`specs/06-Decision-Records/ADR-016-security-authentication.md` - -## Security Checklist (Before Every Commit) - -- [ ] Input validation implemented (Zod/class-validator) -- [ ] RBAC/CASL permissions checked -- [ ] No SQL injection vulnerabilities -- [ ] File upload validation (whitelist + ClamAV) -- [ ] Rate limiting applied to auth endpoints -- [ ] AI boundary enforcement (ADR-018) - no direct DB/storage access -- [ ] AI audit logging implemented for AI interactions -- [ ] Error handling follows ADR-007 layered classification -- [ ] OWASP Top 10 review passed diff --git a/.windsurf/rules/03-typescript.md b/.windsurf/rules/03-typescript.md deleted file mode 100644 index 443740f..0000000 --- a/.windsurf/rules/03-typescript.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -trigger: always_on ---- - -# TypeScript Rules - -## Strict Requirements - -- **Strict Mode** — all strict checks enforced -- **ZERO `any` types** — use proper types or `unknown` + narrowing -- **ZERO `console.log`** — NestJS `Logger` (backend); remove before commit (frontend) - -## Comment Language Policy - -- **Comments:** Thai (เข้าใจง่ายสำหรับทีมไทย) -- **Code Identifiers:** English (variables, functions, classes) - -## Error Handling Pattern - -```typescript -// Backend (NestJS) -import { Logger } from '@nestjs/common'; -const logger = new Logger('ServiceName'); - -// Use logger instead of console.log -logger.error('Error message', error.stack); -throw new HttpException('Message', HttpStatus.BAD_REQUEST); - -// Frontend (Next.js) -// Remove all console.log before commit -// Use proper error boundaries and toast notifications -``` diff --git a/.windsurf/rules/04-domain-terminology.md b/.windsurf/rules/04-domain-terminology.md deleted file mode 100644 index 9e12c5a..0000000 --- a/.windsurf/rules/04-domain-terminology.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -trigger: always_on ---- - -# Domain Terminology - -## DMS Glossary - -| ✅ 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 | -| RBAC | Permission System (generic) | - -## Full Glossary - -`specs/00-overview/00-02-glossary.md` - -## Key Spec Files Priority - -Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > others - -| Document | Path | Use When | -| ----------------------- | ----------------------------------------------------------------- | ------------------------------- | -| **Glossary** | `specs/00-overview/00-02-glossary.md` | Verify domain terminology | -| **Schema Tables** | `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | Before writing any query | -| **Data Dictionary** | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | Field meanings + business rules | -| **Edge Cases** | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | Prevent bugs in flows | -| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | UUID-related work | -| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | NestJS patterns | -| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | Next.js patterns | -| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | Coverage goals | diff --git a/.windsurf/rules/05-forbidden-actions.md b/.windsurf/rules/05-forbidden-actions.md deleted file mode 100644 index 1ed488a..0000000 --- a/.windsurf/rules/05-forbidden-actions.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -trigger: always_on ---- - -# Forbidden Actions - -## ❌ Never Do This - -| ❌ Forbidden | ✅ Correct Approach | -| ----------------------------------------------- | ----------------------------------------------- | -| SQL Triggers for business logic | NestJS Service methods | -| `.env` files in production | `docker-compose.yml` environment section | -| TypeORM migration files | Edit schema SQL directly (ADR-009) | -| Inventing table/column names | Verify against `schema-02-tables.sql` | -| `any` TypeScript type | Proper types / generics | -| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | -| `req: any` in controllers | `RequestWithUser` typed interface | -| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | -| Exposing INT PK in API responses | UUIDv7 (ADR-019) | -| AI accessing DB/storage directly | AI → DMS API → DB (ADR-018) | -| Direct file operations bypassing StorageService | `StorageService` for all file moves | -| Inline email/notification sending | BullMQ queue job | -| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | -| AI direct cloud API calls | On-premises Ollama only (ADR-018) | -| AI outputs without human validation | Human-in-the-loop validation required (ADR-020) | - -## Schema Changes (ADR-009) - -- **NO TypeORM migrations** — edit SQL schema directly -- Always check `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` before writing queries -- Update Data Dictionary when changing fields - -## UUID Handling - -See `01-adr-019-uuid.md` for complete UUID rules. - -Quick reminder: - -- ❌ `parseInt(uuid)` → NEVER -- ❌ `Number(uuid)` → NEVER -- ✅ Use UUID string directly diff --git a/.windsurf/rules/08-development-flow.md b/.windsurf/rules/08-development-flow.md deleted file mode 100644 index 80afe6c..0000000 --- a/.windsurf/rules/08-development-flow.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -trigger: always_on ---- - -# Development Flow - -## 🔴 Critical Work — DB / API / Security / Workflow Engine - -**MUST complete all steps:** - -1. **Glossary check** — verify domain terms in `00-02-glossary.md` -2. **Read the spec** — select from Key Spec Files table -3. **Check schema** — verify table/column in `schema-02-tables.sql` -4. **Check data dictionary** — confirm field meanings + business rules -5. **Scan edge cases** — `01-06-edge-cases-and-rules.md` -6. **Check ADRs** — verify decisions align (ADR-009, ADR-018, ADR-019) -7. **Write code** — TypeScript strict, no `any`, no `console.log` - -## 🟡 Normal Work — UI / Feature / Integration - -- Follow existing patterns in codebase -- Check spec for relevant module only -- No need to read all specs - -## 🟢 Quick Fix — Bug Fix / Typo / Style - -- Fix directly -- Add minimal test if logic changed -- Check forbidden patterns before commit - -## Context-Aware Triggers - -| Request | Files to Check | Expected Response | -| -------------------- | ------------------------------------------------------- | --------------------------------------------------- | -| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | -| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases.md` | RHF+Zod + TanStack Query + Thai comments | -| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | -| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | -| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | -| "ตรวจสอบ permission" | `seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | -| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | -| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | diff --git a/.windsurf/rules/09-commit-checklist.md b/.windsurf/rules/09-commit-checklist.md deleted file mode 100644 index 14d4c92..0000000 --- a/.windsurf/rules/09-commit-checklist.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -trigger: always_on ---- - -# Commit Checklist - -## Pre-Commit Verification - -- [ ] UUID pattern verified (no parseInt on UUID) -- [ ] No `any` types in TypeScript -- [ ] No `console.log` in committed code -- [ ] 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 checked (ADR-009, ADR-018, ADR-019) -- [ ] Glossary terms used correctly -- [ ] Error handling complete (Logger + HttpException) -- [ ] i18n keys used instead of hardcode text -- [ ] Cache invalidation when data modified -- [ ] Security checklist passed (OWASP Top 10) - -## Commit Message Format - -``` -type(scope): description - -[optional body] -``` - -Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` - -Examples: -- `feat(correspondence): add originator organization validation` -- `fix(uuid): correct parseInt usage to string comparison` -- `spec(agents): bump to v1.8.5 - refactor structure` diff --git a/.windsurf/rules/10-error-handling.md b/.windsurf/rules/10-error-handling.md deleted file mode 100644 index 3e7eb70..0000000 --- a/.windsurf/rules/10-error-handling.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -trigger: always_on ---- - -# ADR-007 Error Handling Strategy - -## CRITICAL RULES - -- **ALWAYS** use layered error classification (Validation, Business, System) -- **NEVER** expose technical details to end users -- **ALWAYS** provide user-friendly error messages with recovery guidance -- **ALWAYS** log technical details for debugging -- **NEVER** use generic error messages without context - -## Error Classification - -| Error Type | Description | User Message | Technical Log | -|------------|-------------|--------------|---------------| -| **Validation** | Input validation failures | Clear field-level errors | Full validation details | -| **Business** | Business rule violations | Actionable guidance | Business context + user ID | -| **System** | Infrastructure failures | Generic "try again" | Full stack trace + metrics | - -## Backend Pattern (NestJS) - -```typescript -// Custom Exception Hierarchy -export class BusinessException extends HttpException { - constructor( - message: string, - userMessage: string, - recoveryAction?: string, - errorCode?: string - ) { - super({ message, userMessage, recoveryAction, errorCode }, 400); - } -} - -// Global Exception Filter -@Catch() -export class GlobalExceptionFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost) { - // Classify error and provide appropriate response - // Log technical details - // Return user-friendly message - } -} -``` - -## Frontend Pattern (Next.js) - -```typescript -// Error Display Component -const ErrorDisplay = ({ error, onRetry }) => { - const userMessage = error.userMessage || 'เกิดข้อผิดพลาด'; - const recoveryAction = error.recoveryAction; - - return ( -
-

{userMessage}

- {recoveryAction &&

{recoveryAction}

} - {onRetry && } -
- ); -}; -``` - -## Required Implementation - -- [ ] Global Exception Filter with layered classification -- [ ] Custom exception hierarchy (Validation, Business, System) -- [ ] Standardized error response DTOs -- [ ] Frontend error display components -- [ ] Error recovery mechanisms where applicable - -## Related Documents - -- `specs/06-Decision-Records/ADR-007-error-handling-strategy.md` -- `specs/06-Decision-Records/ADR-010-logging-monitoring-strategy.md` diff --git a/.windsurf/rules/11-ai-integration.md b/.windsurf/rules/11-ai-integration.md deleted file mode 100644 index fbb8c5f..0000000 --- a/.windsurf/rules/11-ai-integration.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -trigger: always_on ---- - -# ADR-020 AI Integration Architecture - -## CRITICAL RULES - -- **ALWAYS** follow ADR-018 AI boundary policy (isolation on Admin Desktop) -- **ALWAYS** use RFA-First approach for AI implementation -- **NEVER** allow AI direct database/storage access -- **ALWAYS** implement human-in-the-loop validation -- **NEVER** send sensitive data to cloud AI services - -## AI Integration Patterns - -### Architecture Overview - -``` -Frontend → AI Gateway API → Admin Desktop (Ollama) → Backend Validation -``` - -### Key Components - -| Component | Location | Purpose | -|-----------|----------|---------| -| **AI Gateway** | Backend (NestJS) | API endpoints, validation, audit logging | -| **Ollama Engine** | Admin Desktop (Desk-5439) | LLM inference (Gemma 4) | -| **OCR Engine** | Admin Desktop (Desk-5439) | Thai/English text extraction | -| **Orchestrator** | QNAP NAS (n8n) | Workflow management | - -## Backend Implementation (NestJS) - -```typescript -// AI Module with boundary enforcement -@Module({ - controllers: [AiController], - providers: [AiService, AiGateway], - exports: [AiService], -}) -export class AiModule { - constructor() { - // Enforce ADR-018 boundaries - } -} - -// AI Service with validation -@Injectable() -export class AiService { - async extractMetadata(documentId: string): Promise { - // 1. Validate permissions - // 2. Send to Admin Desktop AI - // 3. Validate AI response - // 4. Log audit trail - // 5. Return validated results - } -} -``` - -## Frontend Pattern (Next.js) - -```typescript -// Document Review Form (reusable component) -const DocumentReviewForm = ({ document, aiSuggestions }) => { - return ( -
- - - - - - - - ); -}; -``` - -## Security Requirements - -- **AI Isolation:** All AI processing on Admin Desktop only -- **Data Privacy:** No cloud AI services, on-premises only -- **Audit Trail:** Log all AI interactions and human validations -- **Rate Limiting:** Prevent AI abuse and resource exhaustion -- **Validation:** All AI outputs must be validated before use - -## Required Implementation - -- [ ] AiModule with ADR-018 boundary enforcement -- [ ] AI Gateway API endpoints with validation -- [ ] DocumentReviewForm reusable component -- [ ] Admin Desktop Ollama + PaddleOCR setup -- [ ] n8n workflow orchestration -- [ ] AI audit logging and monitoring -- [ ] Human-in-the-loop validation workflows - -## Related Documents - -- `specs/06-Decision-Records/ADR-018-ai-boundary.md` -- `specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md` -- `specs/06-Decision-Records/ADR-017-ollama-data-migration.md` diff --git a/.windsurf/workflows/00-speckit.all.md b/.windsurf/workflows/00-speckit.all.md index d9736e7..504fba7 100644 --- a/.windsurf/workflows/00-speckit.all.md +++ b/.windsurf/workflows/00-speckit.all.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Run the full speckit pipeline from specification to analysis in one command. --- diff --git a/.windsurf/workflows/01-speckit.constitution.md b/.windsurf/workflows/01-speckit.constitution.md index 96544a0..0e51978 100644 --- a/.windsurf/workflows/01-speckit.constitution.md +++ b/.windsurf/workflows/01-speckit.constitution.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync. --- diff --git a/.windsurf/workflows/02-speckit.specify.md b/.windsurf/workflows/02-speckit.specify.md index 69fd061..ef504ce 100644 --- a/.windsurf/workflows/02-speckit.specify.md +++ b/.windsurf/workflows/02-speckit.specify.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Create or update the feature specification from a natural language feature description. --- diff --git a/.windsurf/workflows/03-speckit.clarify.md b/.windsurf/workflows/03-speckit.clarify.md index 9217be3..df5b4f3 100644 --- a/.windsurf/workflows/03-speckit.clarify.md +++ b/.windsurf/workflows/03-speckit.clarify.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 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. --- diff --git a/.windsurf/workflows/04-speckit.plan.md b/.windsurf/workflows/04-speckit.plan.md index 456b83c..a57993f 100644 --- a/.windsurf/workflows/04-speckit.plan.md +++ b/.windsurf/workflows/04-speckit.plan.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Execute the implementation planning workflow using the plan template to generate design artifacts. --- diff --git a/.windsurf/workflows/05-speckit.tasks.md b/.windsurf/workflows/05-speckit.tasks.md index 54967d0..845d066 100644 --- a/.windsurf/workflows/05-speckit.tasks.md +++ b/.windsurf/workflows/05-speckit.tasks.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts. --- diff --git a/.windsurf/workflows/06-speckit.analyze.md b/.windsurf/workflows/06-speckit.analyze.md index a177a65..6ee384e 100644 --- a/.windsurf/workflows/06-speckit.analyze.md +++ b/.windsurf/workflows/06-speckit.analyze.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation. --- diff --git a/.windsurf/workflows/07-speckit.implement.md b/.windsurf/workflows/07-speckit.implement.md index 9a23850..f95d024 100644 --- a/.windsurf/workflows/07-speckit.implement.md +++ b/.windsurf/workflows/07-speckit.implement.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Execute the implementation plan by processing and executing all tasks defined in tasks.md --- diff --git a/.windsurf/workflows/08-speckit.checker.md b/.windsurf/workflows/08-speckit.checker.md index 821544b..af76d1f 100644 --- a/.windsurf/workflows/08-speckit.checker.md +++ b/.windsurf/workflows/08-speckit.checker.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Run static analysis tools and aggregate results. --- diff --git a/.windsurf/workflows/09-speckit.tester.md b/.windsurf/workflows/09-speckit.tester.md index 80f1eab..3bd0459 100644 --- a/.windsurf/workflows/09-speckit.tester.md +++ b/.windsurf/workflows/09-speckit.tester.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Execute tests, measure coverage, and report results. --- diff --git a/.windsurf/workflows/10-speckit.reviewer.md b/.windsurf/workflows/10-speckit.reviewer.md index e5e18ef..eddce5a 100644 --- a/.windsurf/workflows/10-speckit.reviewer.md +++ b/.windsurf/workflows/10-speckit.reviewer.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Perform code review with actionable feedback and suggestions. --- diff --git a/.windsurf/workflows/11-speckit.validate.md b/.windsurf/workflows/11-speckit.validate.md index fbc20d1..db7ae3a 100644 --- a/.windsurf/workflows/11-speckit.validate.md +++ b/.windsurf/workflows/11-speckit.validate.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Validate that implementation matches specification requirements. --- diff --git a/.windsurf/workflows/create-backend-module.md b/.windsurf/workflows/create-backend-module.md index 78cf13d..f554bc7 100644 --- a/.windsurf/workflows/create-backend-module.md +++ b/.windsurf/workflows/create-backend-module.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Create a new NestJS backend feature module following project standards --- @@ -13,7 +14,7 @@ Follows `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` and ADR-00 1. **Verify requirements exist** — confirm the feature is in `specs/01-Requirements/` before starting -// turbo 2. **Check schema** — read `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql` for relevant tables +// turbo 2. **Check schema** — read `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema.sql` for relevant tables 3. **Scaffold module folder** @@ -30,17 +31,17 @@ backend/src/modules// └── .controller.spec.ts ``` -4. **Create Entity** — map ONLY columns defined in the schema SQL. Use TypeORM decorators. Add `@VersionColumn()` if the entity needs optimistic locking. +// turbo 4. **Create Entity** — map ONLY columns defined in the schema SQL. Use TypeORM decorators. Add `@VersionColumn()` if the entity needs optimistic locking. -5. **Create DTOs** — use `class-validator` decorators. Never use `any`. Validate all inputs. +// turbo 5. **Create DTOs** — use `class-validator` decorators. Never use `any`. Validate all inputs. -6. **Create Service** — inject repository via constructor DI. Use transactions for multi-step writes. Add `Idempotency-Key` guard for POST/PUT/PATCH operations. +// turbo 6. **Create Service** — inject repository via constructor DI. Use transactions for multi-step writes. Add `Idempotency-Key` guard for POST/PUT/PATCH operations. -7. **Create Controller** — apply `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`. Use proper HTTP status codes. Document with `@ApiTags` and `@ApiOperation`. +// turbo 7. **Create Controller** — apply `@UseGuards(JwtAuthGuard, CaslAbilityGuard)`. Use proper HTTP status codes. Document with `@ApiTags` and `@ApiOperation`. -8. **Register in Module** — add to `imports`, `providers`, `controllers`, `exports` as needed. +// turbo 8. **Register in Module** — add to `imports`, `providers`, `controllers`, `exports` as needed. -9. **Register in AppModule** — import the new module in `app.module.ts`. +// turbo 9. **Register in AppModule** — import the new module in `app.module.ts`. // turbo 10. **Write unit test** — cover service methods with Jest mocks. Run: diff --git a/.windsurf/workflows/create-frontend-page.md b/.windsurf/workflows/create-frontend-page.md index 22c4b2e..2eb8aa2 100644 --- a/.windsurf/workflows/create-frontend-page.md +++ b/.windsurf/workflows/create-frontend-page.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Create a new Next.js App Router page following project standards --- diff --git a/.windsurf/workflows/deploy.md b/.windsurf/workflows/deploy.md index 4067162..eda3f1d 100644 --- a/.windsurf/workflows/deploy.md +++ b/.windsurf/workflows/deploy.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Deploy the application via Gitea Actions to QNAP Container Station --- diff --git a/.windsurf/workflows/schema-change.md b/.windsurf/workflows/schema-change.md index ef5afb2..04dca69 100644 --- a/.windsurf/workflows/schema-change.md +++ b/.windsurf/workflows/schema-change.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Manage database schema changes following ADR-009 (no migrations, modify SQL directly) --- @@ -18,7 +19,7 @@ Follows `specs/06-Decision-Records/ADR-009-database-strategy.md` — **NO TypeOR 1. **Read current schema** — load the full schema file: ``` -specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql +specs/03-Data-and-Storage/lcbp3-v1.8.0-schema.sql ``` 2. **Read data dictionary** — understand current field definitions: @@ -35,7 +36,7 @@ specs/03-Data-and-Storage/03-01-data-dictionary.md - Indexes being added/modified - Seed data impact (if any) -4. **Modify schema SQL** — edit `specs/03-Data-and-Storage/lcbp3-v1.7.0-schema.sql`: +4. **Modify schema SQL** — edit `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema.sql`: - Add/modify table definitions - Maintain consistent formatting (uppercase SQL keywords, lowercase identifiers) - Add inline comments for new columns explaining purpose @@ -52,8 +53,8 @@ specs/03-Data-and-Storage/03-01-data-dictionary.md - Add enum value definitions if applicable 6. **Update seed data** (if applicable): - - `specs/03-Data-and-Storage/lcbp3-v1.7.0-seed-basic.sql` — for reference/lookup data - - `specs/03-Data-and-Storage/lcbp3-v1.7.0-seed-permissions.sql` — for new CASL permissions + - `specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-basic.sql` — for reference/lookup data + - `specs/03-Data-and-Storage/lcbp3-v1.8.0-seed-permissions.sql` — for new CASL permissions 7. **Update TypeORM entity** — modify corresponding `backend/src/modules//entities/*.entity.ts`: - Map ONLY columns defined in schema SQL diff --git a/.windsurf/workflows/speckit.prepare.md b/.windsurf/workflows/speckit.prepare.md index d7fb5f7..0570cf9 100644 --- a/.windsurf/workflows/speckit.prepare.md +++ b/.windsurf/workflows/speckit.prepare.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Execute the full preparation pipeline (Specify -> Clarify -> Plan -> Tasks -> Analyze) in sequence. --- diff --git a/.windsurf/workflows/util-speckit.checklist.md b/.windsurf/workflows/util-speckit.checklist.md index 49aa2d9..4ac2850 100644 --- a/.windsurf/workflows/util-speckit.checklist.md +++ b/.windsurf/workflows/util-speckit.checklist.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Generate a custom checklist for the current feature based on user requirements. --- diff --git a/.windsurf/workflows/util-speckit.diff.md b/.windsurf/workflows/util-speckit.diff.md index da3dd20..359523c 100644 --- a/.windsurf/workflows/util-speckit.diff.md +++ b/.windsurf/workflows/util-speckit.diff.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Compare two versions of a spec or plan to highlight changes. --- diff --git a/.windsurf/workflows/util-speckit.migrate.md b/.windsurf/workflows/util-speckit.migrate.md index cd2e5b4..3c18266 100644 --- a/.windsurf/workflows/util-speckit.migrate.md +++ b/.windsurf/workflows/util-speckit.migrate.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Migrate existing projects into the speckit structure by generating spec.md, plan.md, and tasks.md from existing code. --- diff --git a/.windsurf/workflows/util-speckit.quizme.md b/.windsurf/workflows/util-speckit.quizme.md index 11f70af..f1fb9c0 100644 --- a/.windsurf/workflows/util-speckit.quizme.md +++ b/.windsurf/workflows/util-speckit.quizme.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Challenge the specification with Socratic questioning to identify logical gaps, unhandled edge cases, and robustness issues. --- diff --git a/.windsurf/workflows/util-speckit.status.md b/.windsurf/workflows/util-speckit.status.md index b2f5089..82550dd 100644 --- a/.windsurf/workflows/util-speckit.status.md +++ b/.windsurf/workflows/util-speckit.status.md @@ -1,4 +1,5 @@ --- +auto_execution_mode: 0 description: Display a dashboard showing feature status, completion percentage, and blockers. --- diff --git a/.windsurf/workflows/util-speckit.taskstoissues.md b/.windsurf/workflows/util-speckit.taskstoissues.md deleted file mode 100644 index 0cdac6e..0000000 --- a/.windsurf/workflows/util-speckit.taskstoissues.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts. ---- - -# Workflow: speckit.taskstoissues - -1. **Context Analysis**: - - The user has provided an input prompt. Treat this as the primary input for the skill. - -2. **Load Skill**: - - Use the `view_file` tool to read the skill file at: `.agents/skills/speckit.taskstoissues/SKILL.md` - -3. **Execute**: - - Follow the instructions in the `SKILL.md` exactly. - - Apply the user's prompt as the input arguments/context for the skill's logic. - -4. **On Error**: - - If `tasks.md` is missing: Run `/speckit.tasks` first diff --git a/.windsurfrules b/.windsurfrules deleted file mode 100644 index 2714f62..0000000 --- a/.windsurfrules +++ /dev/null @@ -1,390 +0,0 @@ -# NAP-DMS Project Context & Rules -- For: Windsurf Cascade -- Version: 1.8.6 | Last synced from repo: 2026-04-10 -- Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) - ---- - -## 🧠 Role & Persona - -Act as a **Senior Full Stack Developer** specialized in: - -- NestJS, Next.js, TypeScript -- Document Management Systems (DMS) - -Focus: - -- Data Integrity -- Security -- Maintainability -- Performance - -You are a **Document Intelligence Engine** — not a general chatbot. -Every response must be **precise**, **spec-compliant**, and **production-ready**. - ---- - -## 🧩 Thought & Planning Protocol (Powered by Everything-Claude-Code) - -Before writing any code or taking any action in Tier 1 and Tier 2, the AI must demonstrate the following thinking process: - -### 1. Analysis Phase (Explore & Analyze) - -Problem Understanding: Restate what the user wants in clear, unambiguous terms. -Context Search: Identify the relevant Spec files or ADRs from the "Key Spec Files" table that must be read before starting. -Constraints Identification: Identify key constraints (e.g. Security rules, UUID patterns, or Domain terminology). - -### 2. Planning Phase (Plan) - -Alternative Exploration: Present at least 2 solution approaches (where possible) with pros/cons analysis. -Step-by-Step Roadmap: Write a file-by-file plan of changes before executing. -Verification Plan: Specify how to verify the work is complete (e.g. "which unit tests to write" or "which file to check the schema in"). - -### 3. Execution & Refinement (Execute & Refine) - -Follow the plan step by step, and pause to ask if any uncertainty arises. -If significant logic changes are made, summarize what was done for the user after completion. - ---- - -## ⚙️ DMS Workflow Engine Protocol - -กฎนี้ใช้คุมการเขียน Logic ส่วนการไหลของเอกสาร (RFA, Transmittal, Correspondence) เพื่อป้องกันปัญหา Race Condition และรักษาความถูกต้องของสถานะเอกสาร: - -- **State Management:** ทุกการเปลี่ยนสถานะของ Workflow ต้องตรวจสอบสถานะปัจจุบันจากฐานข้อมูลก่อนเสมอ เพื่อป้องกันการอนุมัติซ้ำซ้อน (ดูตัวอย่างใน `05-06-code-snippets.md` `[workflow-transition]`) -- **Concurrency Control:** หากมีการเจนเลขที่เอกสาร (Document Numbering) ต้องใช้ **Redis Redlock** หรือ **TypeORM `@VersionColumn`** เท่านั้น ห้ามใช้ logic ฝั่งแอปพลิเคชันเพียงอย่างเดียว (ADR-002) -- **Background Jobs:** งานที่ต้องใช้เวลานานหรือการแจ้งเตือน (Email/Notification) ต้องถูกส่งไปทำที่ **BullMQ** ห้ามเขียนแบบ Inline ใน Service (ADR-008) -- **Term Consistency:** ห้ามใช้คำทั่วไปอย่าง "Approval Flow" ให้ใช้ **"Workflow Engine"** และห้ามใช้ "Letter" ให้ใช้ **"Correspondence"** ตามที่กำหนดใน Glossary - ---- - -## 🛡️ Security & Integrity Audit Protocol - -กฎนี้จะช่วยให้ AI ทำหน้าที่เป็น Gatekeeper ก่อนที่คุณจะ Commit โค้ด โดยเน้นไปที่ **Tier 1 — CRITICAL**: - -- **UUID Validation:** ทุกครั้งที่มีการรับค่า ID จาก API หรือ URL ต้องตรวจสอบว่าเป็น **UUIDv7** และห้ามใช้ `parseInt()` หรือตัวดำเนินการทางคณิตศาสตร์กับค่านี้เด็ดขาด (ADR-019) -- **RBAC Check:** การสร้าง API ใหม่ต้องมี **CASL Guard** และตรวจสอบสิทธิ์แบบ 4-Level RBAC Matrix เสมอ (ADR-016) -- **Data Isolation:** หากมีการใช้ฟีเจอร์ AI ต้องมั่นใจว่ารันผ่าน **Ollama บน Admin Desktop** เท่านั้น และห้ามให้ AI เข้าถึง Database หรือ Storage โดยตรง (ต้องผ่าน DMS API เท่านั้น) (ADR-018) -- **Input Sanitization:** ไฟล์อัปโหลดต้องผ่านการตรวจสอบแบบ **Two-Phase** (Temp → Commit) และต้องสแกนด้วย **ClamAV** ก่อนย้ายเข้า Permanent Storage (ADR-016) - ---- - -## 🧭 Rule Enforcement Tiers - -### 🔴 Tier 1 — CRITICAL (CI BLOCKER) - -Build fails immediately if violated: - -- Security (Auth, RBAC, Validation) -- UUID Strategy (ADR-019) — no `parseInt` / `Number` / `+` on UUID -- Database correctness — verify schema before writing queries -- File upload security (ClamAV + whitelist) -- AI validation boundary (ADR-018) -- Error handling strategy (ADR-007) -- Forbidden patterns: `any`, `console.log`, UUID misuse - -### 🟡 Tier 2 — IMPORTANT (CODE REVIEW) - -Must fix before merge: - -- Architecture patterns (thin controller, business logic in service) -- Test coverage (80%+ business logic, 70%+ backend overall) -- Cache invalidation -- Naming conventions - -### 🟢 Tier 3 — GUIDELINES - -Best practice — follow when possible: - -- Code style / formatting (Prettier handles) -- Comment completeness -- Minor optimizations - ---- - -## 🗂️ Key Spec Files (Always Check Before Writing Code) - -Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > others - -| Document | Path | Status | Use When | -| --------------------------- | -------------------------------------------------------------------- | ----------- | ------------------------------------ | -| **Glossary** | `specs/00-overview/00-02-glossary.md` | — | Verify domain terminology | -| **Schema Tables** | `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | — | Before writing any query | -| **Data Dictionary** | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | — | Field meanings + business rules | -| **RBAC Matrix** | `specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md` | — | Permission levels + roles | -| **Edge Cases** | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | — | Prevent bugs in flows | -| **ADR-001 Workflow Engine** | `specs/06-Decision-Records/ADR-001-unified-workflow-engine.md` | ✅ Active | DSL-based workflow implementation | -| **ADR-002 Doc Numbering** | `specs/06-Decision-Records/ADR-002-document-numbering-strategy.md` | ✅ Active | Document number generation + locking | -| **ADR-007 Error Handling** | `specs/06-Decision-Records/ADR-007-error-handling-strategy.md` | ✅ Active | Error patterns & recovery | -| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification | -| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly | -| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security | -| **ADR-018 AI Boundary** | `specs/06-Decision-Records/ADR-018-ai-boundary.md` | ✅ Active | AI isolation rules | -| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | -| **ADR-020 AI Integration** | `specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md` | 🔄 Proposed | AI architecture patterns | -| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns | -| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns | -| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals | -| **Git Conventions** | `specs/05-Engineering-Guidelines/05-05-git-conventions.md` | — | Commit/branch naming | -| **Code Snippets** | `specs/05-Engineering-Guidelines/05-06-code-snippets.md` | — | Reusable patterns | -| **i18n Guidelines** | `specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md` | — | Localization rules | -| **Release Policy** | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | — | Before deploy/hotfix | -| **UAT Criteria** | `specs/01-Requirements/01-05-acceptance-criteria.md` | — | Feature completeness | - ---- - -## 🆔 Identifier Strategy (ADR-019) — CRITICAL - -| Context | Type | Notes | -| ---------------- | ------------------------- | ------------------------------------------- | -| Internal / DB FK | `INT AUTO_INCREMENT` | Never exposed in API | -| Public API / URL | `UUIDv7` (MariaDB native) | Stored as BINARY(16), no transformer needed | -| Entity Property | `publicId: string` | Exposed directly in API (no transformation) | -| API Response | `publicId: string` (UUID) | INT `id` has `@Exclude()` — never appears | - -### ✅ Updated Pattern (March 2026) - -**Backend:** `UuidBaseEntity` exposes `publicId` directly — no `@Expose({ name: 'id' })` transformation - -**Frontend:** Use `publicId` only — no `uuid` or `id` fallbacks: - -```typescript -// ✅ CORRECT — Use publicId only -type ProjectOption = { - publicId?: string; // No uuid, no id fallback - projectName?: string; -}; - -// ❌ WRONG — Multiple identifiers cause confusion -type ProjectOption = { - publicId?: string; - uuid?: string; // Don't do this - id?: number; // Don't do this -}; -``` - -### ❌ Forbidden UUID Patterns - -```typescript -// ❌ NEVER use parseInt on UUID -parseInt(projectId); // "0195..." → 19 (WRONG!) - -// ❌ NEVER use id ?? '' fallback -const value = c.publicId ?? c.id ?? ''; // Wrong! - -// ✅ CORRECT — Use publicId only -const value = c.publicId; // "019505a1-7c3e-7000-8000-abc123def456" -``` - -Read `specs/05-Engineering-Guidelines/05-07-hybrid-uuid-implementation-plan.md` before any UUID-related work. - ---- - -## 🛡️ Security Rules (Non-Negotiable) - -1. **Idempotency:** All critical `POST`/`PUT`/`PATCH` MUST validate `Idempotency-Key` header -2. **Two-Phase File Upload:** Upload → Temp → Commit → Permanent -3. **Race Conditions:** Redis Redlock + TypeORM `@VersionColumn` for Document Numbering -4. **Validation:** Zod (frontend) + class-validator (backend DTO) -5. **Password:** bcrypt 12 salt rounds, min 8 chars, rotate every 90 days -6. **Rate Limiting:** `ThrottlerGuard` on all auth endpoints -7. **File Upload:** Whitelist PDF/DWG/DOCX/XLSX/ZIP, max 50MB, ClamAV scan -8. **AI Isolation (ADR-018):** Ollama on Admin Desktop ONLY — NO direct DB/storage access -9. **Error Handling (ADR-007):** Use layered error classification with user-friendly messages -10. **AI Integration (ADR-020):** RFA-First approach with unified pipeline architecture - -Full details: `specs/06-Decision-Records/ADR-016-security-authentication.md` - ---- - -## 📐 TypeScript Rules - -- **Strict Mode** — all strict checks enforced -- **ZERO `any` types** — use proper types or `unknown` + narrowing -- **ZERO `console.log`** — NestJS `Logger` (backend); remove before commit (frontend) - ---- - -## 🏷️ Domain Terminology - -| ✅ 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 | -| RBAC | Permission System (generic) | - -Full glossary: `specs/00-overview/00-02-glossary.md` - ---- - -## 🚫 Forbidden Actions - -| ❌ Forbidden | ✅ Correct Approach | ⚠️ Why | -| ----------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------- | -| SQL Triggers for business logic | NestJS Service methods | Untestable; bypasses audit log | -| `.env` files in production | `docker-compose.yml` environment section | Secrets exposed in version control | -| TypeORM migration files | Edit schema SQL directly (ADR-009) | Migration drift risk; schema managed via SQL delta | -| Inventing table/column names | Verify against `schema-02-tables.sql` | Schema mismatch causes silent runtime errors | -| `any` TypeScript type | Proper types / generics | Defeats strict mode; hides runtime type errors | -| `console.log` in committed code | NestJS Logger (backend) / remove (frontend) | Log flooding in production; risk of data leakage | -| `req: any` in controllers | `RequestWithUser` typed interface | Type safety lost; auth context unreachable | -| `parseInt()` on UUID values | Use UUID string directly (ADR-019) | `"019505…"` parsed to integer `19` — silently wrong | -| Exposing INT PK in API responses | UUIDv7 `publicId` (ADR-019) | Leaks row count; enables DB enumeration attacks | -| AI accessing DB/storage directly | AI → DMS API → DB (ADR-018) | Bypasses RBAC, audit trail, and validation layer | -| Direct file operations bypassing StorageService | `StorageService` for all file moves | Orphaned files; broken ClamAV scan; no audit trail | -| Inline email/notification sending | BullMQ queue job (ADR-008) | Blocks request thread; no retry on transient failure | -| Deploying without Release Gates | Complete `04-08-release-management-policy.md` | Unverified deploy risks data loss in production | -| AI direct cloud API calls | On-premises Ollama only (ADR-018) | Data privacy violation; no audit control | -| AI outputs without human validation | Human-in-the-loop validation required (ADR-020) | Unvalidated AI metadata corrupts document records | - ---- - -## 🚧 Out of Scope — Never Do Without Explicit Approval - -The following actions MUST NOT be performed autonomously. **Stop and ask for confirmation** before proceeding: - -| ❌ Never Do Autonomously | ⚠️ Why Approval Is Required | -| --------------------------------------------------------------- | ---------------------------------------------------------------- | -| `DROP` or `RENAME` a column / table | Irreversible data loss — requires DBA + PM sign-off | -| Push directly to `main` / `master` branch | Bypasses CI, code review, and release gates | -| Generate or insert seed data into production database | May corrupt live data or violate business state invariants | -| Delete files from permanent storage | Files may be referenced in active documents or audit trails | -| Modify RBAC permission matrix without security team approval | Defines access control for all users — security boundary change | -| Upgrade major library versions (NestJS, Next.js, TypeORM, etc.) | Breaking changes require full regression test cycle | -| Disable or modify authentication / authorization guards | Creates unguarded endpoints — immediate security risk | -| Change Redis lock TTL or disable Redlock | Risk of document number race condition (ADR-002) | -| Create or supersede an ADR unilaterally | Architecture decisions require team consensus and review process | -| Add new columns to production tables without schema review | Must update Data Dictionary + downstream queries simultaneously | - ---- - -## 🔄 Development Flow (Tiered) - -### 🔴 Critical Work — DB / API / Security / Workflow Engine - -**MUST complete all steps:** - -1. **Glossary check** — verify domain terms in `00-02-glossary.md` -2. **Read the spec** — select from Key Spec Files table -3. **Check schema** — verify table/column in `schema-02-tables.sql` -4. **Check data dictionary** — confirm field meanings + business rules -5. **Scan edge cases** — `01-06-edge-cases-and-rules.md` -6. **Check ADRs** — verify decisions align (ADR-009, ADR-018, ADR-019) -7. **Write code** — TypeScript strict, no `any`, no `console.log` - -### 🟡 Normal Work — UI / Feature / Integration - -**Steps:** - -1. Follow existing patterns in codebase -2. Check spec for relevant module only -3. Verify no forbidden patterns (`any`, `console.log`, UUID misuse) - -**Expected output:** - -- Functional component or updated service method -- At least 1 unit/snapshot test added or updated -- No new TypeScript errors or ESLint warnings -- PR description reflects the change - -### 🟢 Quick Fix — Bug Fix / Typo / Style - -**Steps:** - -1. Identify root cause before changing code -2. Apply minimal, targeted fix -3. Add regression test if logic changed -4. Verify no forbidden patterns introduced - -**Expected output:** - -- Single focused commit: `fix(scope): description` -- All existing tests still pass (no regressions) -- If logic changed: at least 1 regression test added - ---- - -## 🎯 Context-Aware Triggers - -When user asks about... check these files: - -| Request | Files to Check | Expected Response | -| ----------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| "สร้าง API ใหม่" | `05-02-backend-guidelines.md`, `schema-02-tables.sql` | NestJS Controller + Service + DTO + CASL Guard | -| "แก้ฟอร์ม frontend" | `05-03-frontend-guidelines.md`, `01-06-edge-cases.md` | RHF+Zod + TanStack Query + Thai comments | -| "เพิ่ม field ใหม่" | `ADR-009`, `data-dictionary.md`, `schema-02-tables.sql` | Edit SQL directly + update Data Dictionary + Entity | -| "ตรวจสอบ UUID" | `ADR-019`, `05-07-hybrid-uuid-implementation-plan.md` | UUIDv7 MariaDB native UUID + TransformInterceptor | -| "สร้าง migration" | `ADR-009`, `03-06-migration-business-scope.md` | Edit SQL schema directly + n8n workflow | -| "ตรวจสอบ permission" | `seed-permissions.sql`, `ADR-016` | CASL 4-Level RBAC matrix | -| "deploy production" | `04-08-release-management-policy.md`, `ADR-015` | Release Gates + Blue-Green strategy | -| "เพิ่ม test" | `05-04-testing-strategy.md` | Coverage goals + test patterns | -| "AI integration" | `ADR-018`, `ADR-020` | AI boundary + unified pipeline | -| "Error handling" | `ADR-007` | Layered error classification + recovery | -| "File upload" | `ADR-016`, `05-02-backend-guidelines.md`, `03-Data-and-Storage/03-03-file-storage.md` | Two-phase upload → temp → commit; ClamAV + whitelist | -| "Notifications / Queue" | `ADR-008`, `05-02-backend-guidelines.md` | BullMQ job — never inline; check retry + dead-letter | -| "Add i18n / translate" | `05-08-i18n-guidelines.md` | i18n keys only — no hardcoded text | -| "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | -| "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | -| "ตรวจสอบ Workflow" | `01-06-edge-cases.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | -| "Audit ความปลอดภัย" | `ADR-016`, `ADR-018`, `ADR-019` | ตรวจสอบ UUID pattern, CASL Guard และ AI Boundary | - ---- - -## ✅ Quick Reference Checklist (Before Every Commit) - -- [ ] UUID pattern verified (no parseInt on UUID) -- [ ] No `any` types in TypeScript -- [ ] No `console.log` in committed code -- [ ] Business logic comments in Thai (human devs), technical/library comments in English (AI tools) -- [ ] Code identifiers in English -- [ ] Schema changes via SQL directly (not migration) -- [ ] Test coverage meets targets (Backend 70%+, Business Logic 80%+) -- [ ] Relevant ADRs checked (ADR-007, ADR-009, ADR-018, ADR-019, ADR-020) -- [ ] Glossary terms used correctly -- [ ] Error handling complete (Logger + HttpException) -- [ ] i18n keys used instead of hardcode text -- [ ] Cache invalidation when data modified -- [ ] Security checklist passed (OWASP Top 10) - ---- - -## 📚 Full Documentation - -This file is a **quick reference**. For detailed information: - -- **Architecture:** `specs/02-architecture/` -- **Requirements:** `specs/01-requirements/` -- **Data & Storage:** `specs/03-Data-and-Storage/` -- **Engineering Guidelines:** `specs/05-Engineering-Guidelines/` -- **Decision Records:** `specs/06-Decision-Records/` -- **Infrastructure:** `specs/04-Infrastructure-OPS/` - ---- - -## 🔄 Change Log - -| Version | Date | Changes | Updated By | -| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | -| 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev | -| 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI | -| 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI | -| 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI | -| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI | -| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet | -| 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev | -| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro | - ---- - -**To update this file:** - -1. Edit relevant sections -2. Update Change Log above -3. Bump version number in header -4. Commit: `spec(agents): bump to vX.X.X - ` diff --git a/AGENTS.md b/AGENTS.md index 7162354..6515042 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # NAP-DMS Project Context & Rules - For: Windsurf Cascade (and compatible: Codex CLI, opencode, Amp, Antigravity, AGENTS.md tools) -- Version: 1.8.6 | Last synced from repo: 2026-04-10 +- Version: 1.8.7 | Last synced from repo: 2026-04-14 - Repo: [https://git.np-dms.work/np-dms/lcbp3](https://git.np-dms.work/np-dms/lcbp3) --- @@ -107,30 +107,31 @@ Best practice — follow when possible: Spec priority: **`06-Decision-Records`** > **`05-Engineering-Guidelines`** > others -| Document | Path | Status | Use When | -| --------------------------- | -------------------------------------------------------------------- | ----------- | ------------------------------------ | -| **Glossary** | `specs/00-overview/00-02-glossary.md` | — | Verify domain terminology | -| **Schema Tables** | `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | — | Before writing any query | -| **Data Dictionary** | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | — | Field meanings + business rules | -| **RBAC Matrix** | `specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md` | — | Permission levels + roles | -| **Edge Cases** | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | — | Prevent bugs in flows | -| **ADR-001 Workflow Engine** | `specs/06-Decision-Records/ADR-001-unified-workflow-engine.md` | ✅ Active | DSL-based workflow implementation | -| **ADR-002 Doc Numbering** | `specs/06-Decision-Records/ADR-002-document-numbering-strategy.md` | ✅ Active | Document number generation + locking | -| **ADR-007 Error Handling** | `specs/06-Decision-Records/ADR-007-error-handling-strategy.md` | ✅ Active | Error patterns & recovery | -| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification | -| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly | -| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security | -| **ADR-018 AI Boundary** | `specs/06-Decision-Records/ADR-018-ai-boundary.md` | ✅ Active | AI isolation rules | -| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | -| **ADR-020 AI Integration** | `specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md` | 🔄 Proposed | AI architecture patterns | -| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns | -| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns | -| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals | -| **Git Conventions** | `specs/05-Engineering-Guidelines/05-05-git-conventions.md` | — | Commit/branch naming | -| **Code Snippets** | `specs/05-Engineering-Guidelines/05-06-code-snippets.md` | — | Reusable patterns | -| **i18n Guidelines** | `specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md` | — | Localization rules | -| **Release Policy** | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | — | Before deploy/hotfix | -| **UAT Criteria** | `specs/01-Requirements/01-05-acceptance-criteria.md` | — | Feature completeness | +| Document | Path | Status | Use When | +| ---------------------------- | -------------------------------------------------------------------- | --------- | -------------------------------------- | +| **Glossary** | `specs/00-overview/00-02-glossary.md` | — | Verify domain terminology | +| **Schema Tables** | `specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql` | — | Before writing any query | +| **Data Dictionary** | `specs/03-Data-and-Storage/03-01-data-dictionary.md` | — | Field meanings + business rules | +| **RBAC Matrix** | `specs/01-requirements/01-02-business-rules/01-02-01-rbac-matrix.md` | — | Permission levels + roles | +| **Edge Cases** | `specs/01-Requirements/01-06-edge-cases-and-rules.md` | — | Prevent bugs in flows | +| **ADR-001 Workflow Engine** | `specs/06-Decision-Records/ADR-001-unified-workflow-engine.md` | ✅ Active | DSL-based workflow implementation | +| **ADR-002 Doc Numbering** | `specs/06-Decision-Records/ADR-002-document-numbering-strategy.md` | ✅ Active | Document number generation + locking | +| **ADR-007 Error Handling** | `specs/06-Decision-Records/ADR-007-error-handling-strategy.md` | ✅ Active | Error patterns & recovery | +| **ADR-008 Notifications** | `specs/06-Decision-Records/ADR-008-email-notification-strategy.md` | ✅ Active | BullMQ + multi-channel notification | +| **ADR-009 DB Migration** | `specs/06-Decision-Records/ADR-009-database-migration-strategy.md` | ✅ Active | Schema changes — edit SQL directly | +| **ADR-016 Security** | `specs/06-Decision-Records/ADR-016-security-authentication.md` | ✅ Active | Auth, RBAC, file upload security | +| **ADR-018 AI Boundary** | `specs/06-Decision-Records/ADR-018-ai-boundary.md` | ✅ Active | AI isolation rules | +| **ADR-019 UUID** | `specs/06-Decision-Records/ADR-019-hybrid-identifier-strategy.md` | ✅ Active | UUID-related work | +| **ADR-020 AI Integration** | `specs/06-Decision-Records/ADR-020-ai-intelligence-integration.md` | ✅ Active | AI architecture patterns | +| **ADR-021 Workflow Context** | `specs/06-Decision-Records/ADR-021-workflow-context.md` | ✅ Active | Integrated workflow & step attachments | +| **Backend Guidelines** | `specs/05-Engineering-Guidelines/05-02-backend-guidelines.md` | — | NestJS patterns | +| **Frontend Guidelines** | `specs/05-Engineering-Guidelines/05-03-frontend-guidelines.md` | — | Next.js patterns | +| **Testing Strategy** | `specs/05-Engineering-Guidelines/05-04-testing-strategy.md` | — | Coverage goals | +| **Git Conventions** | `specs/05-Engineering-Guidelines/05-05-git-conventions.md` | — | Commit/branch naming | +| **Code Snippets** | `specs/05-Engineering-Guidelines/05-06-code-snippets.md` | — | Reusable patterns | +| **i18n Guidelines** | `specs/05-Engineering-Guidelines/05-08-i18n-guidelines.md` | — | Localization rules | +| **Release Policy** | `specs/04-Infrastructure-OPS/04-08-release-management-policy.md` | — | Before deploy/hotfix | +| **UAT Criteria** | `specs/01-Requirements/01-05-acceptance-criteria.md` | — | Feature completeness | --- @@ -309,6 +310,25 @@ The following actions MUST NOT be performed autonomously. **Stop and ask for con - All existing tests still pass (no regressions) - If logic changed: at least 1 regression test added +### 🟣 ADR-021 Integration Work - Workflow Engine & Context + +**MUST complete:** + +1. **Read ADR-021** - Integrated workflow & step attachments +2. **Check ADR-001** - Unified workflow engine patterns +3. **Verify WorkflowEngineService** - Polymorphic instance handling +4. **Add workflow fields** - Expose workflowInstanceId, workflowState, availableActions +5. **Include IntegratedBanner** - Frontend workflow lifecycle display +6. **Test workflow transitions** - State changes and action validation + +**Expected output:** + +- Backend services expose workflow context fields +- Frontend pages use IntegratedBanner + WorkflowLifecycle +- Workflow instance creation and state management +- Proper RBAC guards on workflow actions +- Unit tests for workflow transitions + --- ## 🎯 Context-Aware Triggers @@ -333,13 +353,12 @@ When user asks about... check these files: | "Workflow / DSL" | `ADR-001`, `01-03-modules/01-03-06-unified-workflow.md` | DSL state machine + WorkflowEngineService | | "Document numbering" | `ADR-002`, `01-02-business-rules/01-02-02-doc-numbering-rules.md` | Redis Redlock + DB optimistic lock (double-lock) | | "ตรวจสอบ Workflow" | `01-06-edge-cases.md`, `05-02-backend-guidelines.md`, `ADR-001`, `ADR-002` | เช็คการเปลี่ยน State, คิว BullMQ และการล็อกเลขที่เอกสาร | +| "Transmittal submit" | ADR-021, TransmittalService | submit() with EC-RFA-004 validation | +| "Circulation reassign" | ADR-021, CirculationService | reassignRouting() with EC-CIRC-001 | | "Audit ความปลอดภัย" | `ADR-016`, `ADR-018`, `ADR-019` | ตรวจสอบ UUID pattern, CASL Guard และ AI Boundary | ---- +... (rest of the code remains the same) -## ✅ Quick Reference Checklist (Before Every Commit) - -- [ ] UUID pattern verified (no parseInt on UUID) - [ ] No `any` types in TypeScript - [ ] No `console.log` in committed code - [ ] Business logic comments in Thai (human devs), technical/library comments in English (AI tools) @@ -370,16 +389,17 @@ This file is a **quick reference**. For detailed information: ## 🔄 Change Log -| Version | Date | Changes | Updated By | -| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | -| 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev | -| 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI | -| 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI | -| 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI | -| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI | -| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet | -| 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev | -| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro | +| Version | Date | Changes | Updated By | +| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | +| 1.8.7 | 2026-04-14 | + ADR-021 Workflow Context integration, + ADR-021 Integration Work tier, + Transmittal/Circulation context triggers, updated ADR-020 status | Windsurf AI | +| 1.8.6 | 2026-04-10 | + DMS Workflow Engine Protocol, + Security & Integrity Audit Protocol, + 2 Context-Aware Triggers, ADR Status column, Forbidden Why column | Human Dev | +| 1.8.5 | 2026-04-04 | Added ADR-007 error handling, ADR-020 AI integration, updated security rules | Windsurf AI | +| 1.8.4 | 2026-03-24 | Phase 5.4→✅ DONE, Tailwind 3.4.3, ADR count(16), MariaDB UUID note | Windsurf AI | +| 1.8.3 | 2026-03-21 | + Rule Enforcement Tiers (🔴🟡🟢), + Tiered Development Flow | Human Dev + AI | +| 1.8.2 | 2026-03-21 | + Context Triggers, + Code Snippets, + Error Handling, + i18n | Human Dev + AI | +| 1.8.1 | 2026-03-21 | + ADR-019 UUID patterns, + Phase 5.4 pending files | Claude Sonnet | +| 1.8.0 | 2026-03-19 | + Security overrides, + UAT criteria reference | Human Dev | +| 1.7.2 | 2026-03-15 | + AI Boundary rules (ADR-018) | Gemini Pro | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 34345e0..6b40bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,103 @@ # Version History +## 1.8.7 (2026-04-14) + +### feat(workflow): ADR-021 Integration Complete - Transmittals & Circulation + +#### Summary + +Successfully integrated ADR-021 (Integrated Workflow Context & Step-specific Attachments) into Transmittals and Circulation modules. All backend services, frontend pages, and tests are wired to the Unified Workflow Engine. + +#### **Backend Changes (B1-B9)** + +- **WorkflowEngineService**: Added `getInstanceByEntity(entityType, entityId)` for polymorphic workflow instance lookup +- **TransmittalService**: + - Expose `workflowInstanceId`, `workflowState`, `availableActions` in `findOneByUuid()` + - Added purpose filter to `findAll()` + - Added `submit()` with EC-RFA-004 validation (prevents submission if any item correspondence is DRAFT) + - Starts workflow instance `TRANSMITTAL_FLOW_V1` and updates CorrespondenceRevision status +- **TransmittalController**: Added `POST /:uuid/submit` endpoint with RBAC and Audit +- **TransmittalModule**: Imported `WorkflowEngineModule` and `CorrespondenceRevision` +- **CirculationService**: + - Expose workflow fields in `findOneByUuid()` + - Added `reassignRouting()` (EC-CIRC-001) for PENDING routing reassignment + - Added `forceClose()` (EC-CIRC-002) with transactional rollback and reason validation +- **CirculationController**: Added `PATCH /:uuid/routing/:routingId/reassign` and `POST /:uuid/force-close` +- **Circulation Entity**: Added `deadlineDate` column for EC-CIRC-003 Overdue badge +- **Schema Delta**: `05-add-circulation-deadline.sql` per ADR-009 (no migrations) + +#### **Frontend Changes (F1-F7)** + +- **Types**: Extended `Transmittal` and `Circulation` interfaces with workflow fields; added `deadlineDate` to Circulation +- **Hooks**: Created `useTransmittal()` and extended `useCirculation()` hooks with TanStack Query +- **Detail Pages**: + - Both wired with `IntegratedBanner` and `WorkflowLifecycle` using live workflow data + - Circulation page includes EC-CIRC-003 Overdue badge logic (`isOverdue()`) +- **List Page**: Added purpose filter dropdown to `transmittals/page.tsx` + +#### **Tests (T1-T2): 19/19 Passing** + +- **TransmittalService**: 7 tests covering EC-RFA-004 validation, workflow instance creation, and error cases +- **CirculationService**: 12 tests covering EC-CIRC-001 (reassign), EC-CIRC-002 (forceClose), EC-CIRC-003 (deadlineDate exposure) + +#### **Key Technical Decisions** + +- Followed ADR-019 UUID handling (no parseInt, use string UUIDs) +- Used ADR-009 direct schema edits (no TypeORM migrations) +- Enforced RBAC with CASL guards and Audit decorators +- Implemented transactional force-close with proper rollback +- Maintained existing patterns for error handling and service architecture + +#### **Remaining Work** + +- I1: i18n keys for new workflow actions (low priority) + +#### **Files Modified** + +Backend: 11 files (services, controllers, entities, modules, deltas) +Frontend: 7 files (types, hooks, pages, services) +Tests: 2 new spec files with full coverage + +#### **Verification** + +- Backend TS: No errors in modified files +- Frontend TS: No errors in modified files +- Jest: 19/19 tests passing +- All components follow existing patterns and ADRs + +--- + +## 1.8.6 (2026-04-12) + +### feat(workflow): ADR-021 Integrated Workflow Context & Step-specific Attachments + +#### 🏗️ Backend (NestJS) + +- **Added**: `workflow_history_id` column to `attachments` table (Delta SQL: `04-add-workflow-history-id-to-attachments.sql`) +- **Added**: `WorkflowTransitionWithAttachmentsGuard` — validates UUIDv7 `attachmentPublicIds` array on transition requests +- **Added**: `processTransition()` in `WorkflowEngineService` — links step-evidence attachments to `workflow_history` in the same transaction; commits temp→permanent atomically +- **Added**: `GET /workflow-engine/instances/:id/history` endpoint with attachment summaries per step (Redis cached, 5 min TTL) +- **Added**: `GET /files/preview/:publicId` endpoint with inline `Content-Disposition` for PDF/image rendering +- **Added**: `Idempotency-Key` header support on `POST /workflow-engine/instances/:id/transition` (Redis dedup, 24 h TTL) + +#### 🖥️ Frontend (Next.js) + +- **Added**: `IntegratedBanner` component — document header with status badge, priority badge, workflow state, and action buttons (Approve/Reject/Return/Acknowledge/Comment) +- **Added**: `WorkflowLifecycle` component — vertical timeline of workflow history with attachment chips, "current step" badge, and drag-and-drop upload zone +- **Added**: `FilePreviewModal` component — inline PDF/image preview via BlobURL with 404 "ไฟล์ไม่พร้อมใช้งาน" detection +- **Added**: `useWorkflowAction` hook — idempotent workflow transition with UUIDv4 Idempotency-Key, TanStack Query cache invalidation on success +- **Added**: `useWorkflowHistory` hook — fetches step history with attachments per instance +- **Added**: `WorkflowErrorBoundary` class component — catches unexpected failures without crashing the full detail page +- **Added**: i18n support for all new components (`public/locales/th|en/common.json`, `lib/i18n/`, `hooks/use-translations.ts`) +- **Modified**: RFA and Correspondence detail pages — integrated all ADR-021 components end-to-end + +#### 📚 Specs & Documentation + +- **Updated**: `specs/03-Data-and-Storage/03-01-data-dictionary.md` — `attachments.workflow_history_id` field with business rules (ADR-021) +- **Added**: `specs/08-Tasks/ADR-021-workflow-context/tasks.md` — complete task breakdown (47 tasks, Phases 1–8) + +--- + ## 1.8.5 (2026-04-10) ### Specification & ADR Documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d304550..5b4ae00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,7 +75,7 @@ specs/ │ ├── 05-03-frontend-guidelines.md │ └── 05-04-testing-strategy.md │ -├── 06-Decision-Records/ # Architecture Decision Records (21 ADRs) +├── 06-Decision-Records/ # Architecture Decision Records (22 ADRs) │ ├── README.md │ ├── ADR-001-unified-workflow-engine.md │ ├── ADR-002-document-numbering-strategy.md @@ -88,7 +88,8 @@ specs/ │ ├── ADR-017B-ai-document-classification.md │ ├── ADR-018-ai-boundary.md # AI Isolation Policy [★ Patch 1.8.1] │ ├── ADR-019-hybrid-identifier-strategy.md -│ └── ADR-020-ai-intelligence-integration.md +│ ├── ADR-020-ai-intelligence-integration.md +│ └── ADR-021-workflow-context.md # Integrated Workflow Context [★ v1.8.7] │ └── 99-archives/ # ประวัติการทำงานและ Tasks เก่า ├── history/ @@ -98,16 +99,16 @@ specs/ ### 📋 หมวดหมู่เอกสาร -| หมวด | วัตถุประสงค์ | ไฟล์สำคัญ | ผู้ดูแล | -| ----------------------------- | -------------------------------------- | --------------- | ----------------------- | -| **00-Overview** | ภาพรวม, Product Vision, KPI, Training | Gap 1/5/6/9 | Project Manager / PO | -| **01-Requirements** | User Stories, UAT, UI, Edge Cases | Gap 2/3/4/10 | Business Analyst + PO | -| **02-Architecture** | สถาปัตยกรรมและการออกแบบ | — | Tech Lead + Architects | -| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA | -| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team | -| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads | -| **06-Decision-Records** | Architecture Decision Records (21) | ADR-018/019/020 | Tech Lead + Senior Devs | -| **99-archives** | Archived / Tasks | — | All Team Members | +| หมวด | วัตถุประสงค์ | ไฟล์สำคัญ | ผู้ดูแล | +| ----------------------------- | -------------------------------------- | ------------------- | ----------------------- | +| **00-Overview** | ภาพรวม, Product Vision, KPI, Training | Gap 1/5/6/9 | Project Manager / PO | +| **01-Requirements** | User Stories, UAT, UI, Edge Cases | Gap 2/3/4/10 | Business Analyst + PO | +| **02-Architecture** | สถาปัตยกรรมและการออกแบบ | — | Tech Lead + Architects | +| **03-Data-and-Storage** | Schema v1.8.0, Migration Scope | Gap 7 | Backend Lead + DBA | +| **04-Infrastructure-OPS** | Deployment, Operations, Release Policy | Gap 8 | DevOps Team | +| **05-Engineering-Guidelines** | แผนการพัฒนาและ Implementation | — | Development Team Leads | +| **06-Decision-Records** | Architecture Decision Records (22) | ADR-018/019/020/021 | Tech Lead + Senior Devs | +| **99-archives** | Archived / Tasks | — | All Team Members | --- @@ -145,8 +146,6 @@ POST /api/correspondences ``` ```` -```` - #### ❌ ผิด ```markdown @@ -157,7 +156,7 @@ POST /api/correspondences - สร้างได้ - แก้ไขได้ - ส่งได้ -```` +``` ### 3. โครงสร้างเอกสาร @@ -307,6 +306,9 @@ git push origin spec/requirements/update-correspondence - [ ] ตรวจสอบ markdown syntax - [ ] ตรวจสอบ internal links - [ ] เพิ่ม Related Documents +- [ ] ผ่าน L1 Peer Review +- [ ] ผ่าน L2 Technical Review +- [ ] ได้รับ L3 Approval ``` --- @@ -544,18 +546,19 @@ graph LR **Document History**: -| Version | Date | Author | Changes | -| ------- | ---------- | ---------- | ----------------------------------------------------------- | -| 1.0.0 | 2025-01-15 | John Doe | Initial version | -| 1.1.0 | 2025-02-20 | Jane Smith | Add CC support | -| 1.2.0 | 2025-03-10 | John Doe | Update workflow | -| 1.8.1 | 2026-03-21 | Tech Lead | Security hardening, numbering fixes, dependency updates | -| 1.8.5 | 2026-04-10 | Tech Lead | ADR registry complete (21 ADRs), spec documentation updates | +| Version | Date | Author | Changes | +| ------- | ---------- | ---------- | ----------------------------------------------------------------- | +| 1.0.0 | 2025-01-15 | John Doe | Initial version | +| 1.1.0 | 2025-02-20 | Jane Smith | Add CC support | +| 1.8.7 | 2026-04-14 | Tech Lead | ADR-021 integration complete (22 ADRs), workflow context features | +| 1.8.5 | 2026-04-10 | Tech Lead | ADR registry complete (21 ADRs), spec documentation updates | +| 1.8.1 | 2026-03-21 | Tech Lead | Security hardening, numbering fixes, dependency updates | -**Current Version**: 1.8.5 +**Current Version**: 1.8.7 **Status**: Approved -**Last Updated**: 2026-04-10 +**Last Updated**: 2026-04-14 **Security**: 0 vulnerabilities (backend) +**Workflow Engine**: ADR-021 Integrated Context complete ``` ### 5. UUID Conventions (ADR-019) diff --git a/README.md b/README.md index 8dd073c..6b70a81 100644 --- a/README.md +++ b/README.md @@ -3,26 +3,27 @@ > **Laem Chabang Port Phase 3 - Document Management System** > ระบบบริหารจัดการเอกสารโครงการแบบครบวงจร สำหรับโครงการก่อสร้างท่าเรือแหลมฉบังระยะที่ 3 -[![Version](https://img.shields.io/badge/version-1.8.5-blue.svg)](./CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.8.7-blue.svg)](./CHANGELOG.md) [![License](https://img.shields.io/badge/license-Internal-red.svg)]() [![Status](https://img.shields.io/badge/status-UAT%20Ready-brightgreen.svg)]() [![Docs](https://img.shields.io/badge/docs-10%2F10%20Gaps%20Closed-success.svg)](./specs/00-Overview/README.md) --- -## 📈 Current Status (As of 2026-04-10) +## 📈 Current Status (As of 2026-04-14) -**Version 1.8.5 — UAT Ready, ADR Documentation Complete (21 ADRs)** +**Version 1.8.7 — ADR-021 Integration Complete, Production Ready (22 ADRs)** -| Area | Status | หมายเหตุ | -| -------------------- | ------------------------ | ---------------------------------------- | -| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities | -| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 | -| 💾 **Database** | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration Policy | -| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy | -| 🤖 **AI Migration** | 🔄 Pre-migration Setup | n8n + Ollama (ADR-017/018) | -| 🧪 **Testing** | 🔄 UAT Preparation | E2E + Acceptance Criteria ready | -| 🚀 **Deployment** | 📋 Pending Go-Live Gate | Blue-Green on QNAP Container Station | +| Area | Status | หมายเหตุ | +| ---------------------- | ------------------------ | -------------------------------------------------- | +| 🔧 **Backend** | ✅ Production Ready | NestJS 11, Express v5, 0 Vulnerabilities | +| 🎨 **Frontend** | ✅ 100% Complete | Next.js 16.2.0, React 19.2.4, ESLint 9 | +| 💾 **Database** | ✅ Schema v1.8.0 Stable | MariaDB 11.8, No-migration Policy | +| 📘 **Documentation** | ✅ **10/10 Gaps Closed** | Product Vision → Release Policy | +| 🤖 **AI Migration** | 🔄 Pre-migration Setup | n8n + Ollama (ADR-017/018) | +| 🔄 **Workflow Engine** | ✅ ADR-021 Integrated | Transmittals & Circulation with Integrated Context | +| 🧪 **Testing** | 🔄 UAT Preparation | E2E + Acceptance Criteria ready | +| 🚀 **Deployment** | 📋 Pending Go-Live Gate | Blue-Green on QNAP Container Station | --- @@ -40,7 +41,7 @@ LCBP3-DMS เป็นระบบบริหารจัดการเอก - 📝 **Correspondence Management** - จัดการเอกสารโต้ตอบระหว่างองค์กร - 🔧 **RFA Management** - ระบบขออนุมัติเอกสารทางเทคนิค - 📐 **Drawing Management** - จัดการแบบก่อสร้างและแบบคู่สัญญา -- 🔄 **Workflow Engine** - DSL-based workflow สำหรับกระบวนการอนุมัติ +- 🔄 **Workflow Engine** - DSL-based workflow สำหรับกระบวนการอนุมัติ (ADR-021 Integrated Context) - 📊 **Advanced Search** - ค้นหาเอกสารด้วย Elasticsearch - 🔐 **RBAC 4-Level** - ควบคุมสิทธิ์แบบละเอียด (Global, Organization, Project, Contract) - 📁 **Two-Phase File Storage** - จัดการไฟล์แบบ Transactional พร้อม Virus Scanning @@ -241,6 +242,7 @@ lcbp3-dms/ │ │ │ └── json-schema/ # JSON Schema validation │ │ └── main.ts │ ├── test/ # Unit & E2E tests +│ └── uploads/ # File upload storage (temp/ + permanent/) │ └── package.json │ ├── frontend/ # 🎨 Next.js Frontend @@ -275,9 +277,10 @@ lcbp3-dms/ │ │ ├── services/ # Business logic services │ │ └── stores/ # Zustand state stores │ ├── types/ # TypeScript definitions +│ └── public/ # Static assets (locales, favicon, robots.txt) │ └── package.json │ -├── specs/ # 📘 Project Specifications v1.8.1 — 10/10 Gaps Closed +├── specs/ # 📘 Project Specifications v1.8.7 - 10/10 Gaps Closed │ ├── 00-Overview/ # ภาพรวม: Product Vision, KPI Baseline, Training, Stakeholder │ │ ├── 00-03-product-vision.md # Gap 1 — Product Vision Statement │ │ ├── 00-04-stakeholder-signoff-and-risk.md # Gap 5 — Risk & Sign-off @@ -292,19 +295,46 @@ lcbp3-dms/ │ ├── 03-Data-and-Storage/ # Schema v1.8.0 (split 3 files) + 03-06-migration-business-scope.md │ ├── 04-Infrastructure-OPS/ # Ops: Deploy, Monitoring, Security + 04-08-release-management-policy.md │ ├── 05-Engineering-Guidelines/ # มาตรฐานการพัฒนา Backend/Frontend -│ ├── 06-Decision-Records/ # 21 ADRs (ADR-001~020 + ADR-017B) -│ └── 99-archives/ # ประวัติการทำงานและ Tasks เก่า +│ ├── 06-Decision-Records/ # 22 ADRs (ADR-001~021 + ADR-017B) +│ ├── 08-Tasks/ # Task documentation and implementation plans +│ └── 99-archives/ # History and old Tasks │ ├── docs/ # 📚 Legacy documentation ├── infrastructure/ # 🐳 Docker & Deployment configs +├── scripts/ # Utility scripts (bash + powershell) +│ ├── bash/ # Bash scripts +│ ├── powershell/ # PowerShell scripts +│ +├── .agents/ # AI agent workflows and tools +│ ├── skills/ # Agent skills (nestjs-best-practices, next-best-practices, speckit-*) +│ ├── scripts/ # Agent utility scripts +│ ├── tests/ # Agent integration tests +│ +├── .windsurf/ # Windsurf AI workflows +│ ├── workflows/ # Speckit workflow definitions (00-speckit.all, 01-speckit.constitution, etc.) +│ +├── .github/ # GitHub Actions workflows +│ ├── workflows/ # CI/CD pipeline definitions +│ ├── PULL_REQUEST_TEMPLATE.md +│ +├── .gitea/ # Gitea configuration +├── .gemini/ # Gemini AI agent configuration +├── .vscode/ # VS Code settings and extensions +├── .husky/ # Git hooks │ -├── .gemini/ # 🤖 AI agent configuration -├── .agents/ # Agent workflows and tools ├── AGENTS.md # AI agent rules & project context ├── GEMINI.md # AI coding guidelines ├── CONTRIBUTING.md # Contribution guidelines ├── CHANGELOG.md # Version history -└── pnpm-workspace.yaml # Monorepo configuration +├── README.md # This file +├── package.json # Root package.json (monorepo) +├── pnpm-workspace.yaml # Monorepo configuration +├── lcbp3.code-workspace # VS Code workspace configuration +│ +├── output/ # Build and output files +│ ├── pdf/ # Generated PDF documentation +│ +└── ``` --- @@ -313,26 +343,207 @@ lcbp3-dms/ ### เอกสารหลัก (specs/ folder) -| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก | -| ----------------------- | ------------------------------------------------------------ | --------- | --------------------------------------- | -| **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` | -| **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` | -| **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` | -| **UI/UX Wireframes** | 26 Screens, ASCII Wireframes, Design System | Gap 4 ✅ | `01-07-ui-wireframes.md` | -| **Stakeholder & Risk** | Sign-off, Risk Register, Change Control | Gap 5 ✅ | `00-04-stakeholder-signoff-and-risk.md` | -| **KPI Baseline** | 14 KPIs, SQL Queries, Grafana Specs | Gap 6 ✅ | `00-05-kpi-baseline.md` | -| **Migration Scope** | 20K Docs, 3 Tiers, Go/No-Go Gates | Gap 7 ✅ | `03-06-migration-business-scope.md` | -| **Release Policy** | SemVer, 5 Gates, Hotfix, Rollback | Gap 8 ✅ | `04-08-release-management-policy.md` | -| **Training Plan** | Curriculum per Role, UAT Training | Gap 9 ✅ | `00-06-training-plan.md` | -| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` | -| **Schema v1.8.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.8.0-schema-*.sql` | -| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` | -| **ADRs (21)** | All Architecture Decisions incl. ADR-003/004/007/018/019/020 | — | `06-Decision-Records/` | +| เอกสาร | คำอธิบาย | Gap | ไฟล์หลัก | +| ----------------------- | ---------------------------------------------------------------- | --------- | --------------------------------------- | +| **Product Vision** | Vision, Strategic Pillars, Guardrails | Gap 1 ✅ | `00-03-product-vision.md` | +| **User Stories** | 27 Stories, 8 Epics, MoSCoW | Gap 2 ✅ | `01-04-user-stories.md` | +| **Acceptance Criteria** | UAT Criteria, Sign-off Process | Gap 3 ✅ | `01-05-acceptance-criteria.md` | +| **UI/UX Wireframes** | 26 Screens, ASCII Wireframes, Design System | Gap 4 ✅ | `01-07-ui-wireframes.md` | +| **Stakeholder & Risk** | Sign-off, Risk Register, Change Control | Gap 5 ✅ | `00-04-stakeholder-signoff-and-risk.md` | +| **KPI Baseline** | 14 KPIs, SQL Queries, Grafana Specs | Gap 6 ✅ | `00-05-kpi-baseline.md` | +| **Migration Scope** | 20K Docs, 3 Tiers, Go/No-Go Gates | Gap 7 ✅ | `03-06-migration-business-scope.md` | +| **Release Policy** | SemVer, 5 Gates, Hotfix, Rollback | Gap 8 ✅ | `04-08-release-management-policy.md` | +| **Training Plan** | Curriculum per Role, UAT Training | Gap 9 ✅ | `00-06-training-plan.md` | +| **Edge Cases & Rules** | 37 Edge Cases, Business Logic Guards | Gap 10 ✅ | `01-06-edge-cases-and-rules.md` | +| **Schema v1.8.0** | Tables, Views, Indexes (3-file split) | — | `lcbp3-v1.8.0-schema-*.sql` | +| **Data Dictionary** | Field Meanings, Business Rules | — | `03-01-data-dictionary.md` | +| **ADRs (22)** | All Architecture Decisions incl. ADR-003/004/007/018/019/020/021 | - | `06-Decision-Records/` | + +--- + +#### Complete Specifications File Listing + +**00-Overview/ - Project Overview & Vision (7 files)** +| File | Description | +| ---- | ----------- | +| `00-01-quick-start.md` | Quick start guide for the project | +| `00-02-glossary.md` | Technical terminology and domain vocabulary | +| `00-03-product-vision.md` | **Gap 1** - Product Vision, Strategic Pillars, Guardrails | +| `00-04-stakeholder-signoff-and-risk.md` | **Gap 5** - Stakeholder Sign-off, Risk Register | +| `00-05-kpi-baseline.md` | **Gap 6** - 14 KPIs, SQL Queries, Grafana Specs | +| `00-06-training-plan.md` | **Gap 9** - Training Curriculum per Role | +| `README.md` | Overview section index | + +**01-Requirements/ - System Requirements (23 files)** +| File | Description | +| ---- | ----------- | +| `01-01-objectives.md` | Project objectives and goals | +| `01-02-business-rules/` | Business Rules (5 files) | +| `01-02-01-rbac-matrix.md` | RBAC 4-Level permission matrix | +| `01-02-02-doc-numbering-rules.md` | Document numbering business rules | +| `01-02-03-ui-ux-rules.md` | UI/UX design rules and guidelines | +| `01-02-04-non-functional-rules.md` | Non-functional requirements | +| `01-02-05-testing-rules.md` | Testing requirements and rules | +| `01-03-modules/` | Feature Modules (11 files) | +| `01-03-00-index.md` | Modules overview index | +| `01-03-01-project-management.md` | Project & Contract management | +| `01-03-02-correspondence.md` | Correspondence management | +| `01-03-02a-correspondence-rfa-unified-ux-flow.md` | Correspondence-RFA unified UX flow | +| `01-03-03-rfa.md` | Request for Approval (RFA) | +| `01-03-04-contract-drawing.md` | Contract drawing management | +| `01-03-05-shop-drawing.md` | Shop drawing management | +| `01-03-06-unified-workflow.md` | Unified workflow engine | +| `01-03-07-transmittals.md` | Transmittal management | +| `01-03-08-circulation-sheet.md` | Circulation sheet management | +| `01-03-09-logs.md` | System logs and audit trails | +| `01-03-10-json-details.md` | JSON schema details | +| `01-04-user-stories.md` | **Gap 2** - 27 User Stories, 8 Epics, MoSCoW | +| `01-05-acceptance-criteria.md` | **Gap 3** - UAT Acceptance Criteria | +| `01-06-edge-cases-and-rules.md` | **Gap 10** - 37 Edge Cases, Business Logic | +| `01-07-ui-wireframes.md` | **Gap 4** - 26 Screens, ASCII Wireframes | +| `README.md` | Requirements section index | + +**02-Architecture/ - System Architecture (5 files)** +| File | Description | +| ---- | ----------- | +| `02-01-system-context.md` | System context and boundaries | +| `02-02-software-architecture.md` | Software architecture patterns | +| `02-03-network-design.md` | Network design and topology | +| `02-04-api-design.md` | API design principles | +| `README.md` | Architecture section index | + +**03-Data-and-Storage/ - Database & Storage (31 files)** +| File | Description | +| ---- | ----------- | +| `0.md` | Data and storage overview | +| `03-01-data-dictionary.md` | Field meanings, business rules | +| `03-02-db-indexing.md` | Database indexing strategy | +| `03-03-file-storage.md` | File storage architecture | +| `03-04-legacy-data-migration.md` | Legacy data migration plan | +| `03-05-n8n-migration-setup-guide.md` | n8n migration setup guide | +| `03-06-migration-business-scope.md` | **Gap 7** - Migration Business Scope | +| `03-07-OpenRAG.md` | OpenRAG integration | +| `lcbp3-v1.8.0-schema-*.sql` | Schema files (3 files: drop, tables, views-indexes) | +| `lcbp3-v1.8.0-seed-*.sql` | Seed data files (2 files: basic, permissions) | +| `deltas/` | Schema delta files (3 files) | +| `*.sql` | Additional SQL files (22 files) | + +**04-Infrastructure-OPS/ - Infrastructure & Operations (21 files)** +| File | Description | +| ---- | ----------- | +| `04-01-docker-compose.md` | Docker Compose configuration | +| `04-02-backup-recovery.md` | Backup and recovery procedures | +| `04-03-monitoring.md` | System monitoring setup | +| `04-04-deployment-guide.md` | Deployment procedures | +| `04-05-security-operations.md` | Security operations guide | +| `04-06-disaster-recovery.md` | Disaster recovery plan | +| `04-07-incident-response.md` | Incident response procedures | +| `04-08-release-management-policy.md` | **Gap 8** - Release Management Policy | +| `04-09-performance-monitoring.md` | Performance monitoring | +| `04-10-capacity-planning.md` | Capacity planning guide | +| `04-11-maintenance-windows.md` | Maintenance windows | +| `04-12-service-level-agreement.md` | SLA definitions | +| `04-13-change-management.md` | Change management process | +| `04-14-configuration-management.md` | Configuration management | +| `04-15-logging-strategy.md` | Logging strategy | +| `04-16-alerting.md` | Alerting setup | +| `04-17-scaling-strategy.md` | Scaling strategy | +| `04-18-cost-optimization.md` | Cost optimization | +| `04-19-compliance.md` | Compliance requirements | +| `04-20-documentation-maintenance.md` | Documentation maintenance | +| `README.md` | Infrastructure section index | + +**05-Engineering-Guidelines/ - Development Standards (10 files)** +| File | Description | +| ---- | ----------- | +| `05-01-fullstack-js-guidelines.md` | Fullstack JavaScript guidelines | +| `05-02-backend-guidelines.md` | Backend development guidelines | +| `05-03-frontend-guidelines.md` | Frontend development guidelines | +| `05-04-testing-strategy.md` | Testing strategy and procedures | +| `05-05-git-conventions.md` | Git workflow conventions | +| `05-06-code-snippets.md` | Code snippet patterns | +| `05-07-hybrid-uuid-implementation-plan.md` | UUID implementation plan (ADR-019) | +| `05-08-i18n-guidelines.md` | Internationalization guidelines | +| `05-09-performance-guidelines.md` | Performance guidelines | +| `README.md` | Engineering guidelines index | + +**06-Decision-Records/ - Architecture Decisions (26 files)** +| File | Description | +| ---- | ----------- | +| `ADR-001-unified-workflow-engine.md` | Unified Workflow Engine | +| `ADR-002-document-numbering-strategy.md` | Document Numbering Strategy | +| `ADR-003-api-design-strategy.md` | API Design Strategy | +| `ADR-004-database-schema-design-strategy.md` | Database Schema Design | +| `ADR-005-security-strategy.md` | Security Strategy | +| `ADR-006-frontend-architecture.md` | Frontend Architecture | +| `ADR-007-error-handling-strategy.md` | Error Handling Strategy | +| `ADR-008-email-notification-strategy.md` | Email Notification Strategy | +| `ADR-009-database-migration-strategy.md` | Database Migration Strategy | +| `ADR-010-cache-strategy.md` | Cache Strategy | +| `ADR-011-testing-strategy.md` | Testing Strategy | +| `ADR-012-logging-strategy.md` | Logging Strategy | +| `ADR-013-monitoring-strategy.md` | Monitoring Strategy | +| `ADR-014-deployment-strategy.md` | Deployment Strategy | +| `ADR-015-performance-strategy.md` | Performance Strategy | +| `ADR-016-security-authentication.md` | Security Authentication | +| `ADR-017-ollama-data-migration.md` | Ollama Data Migration | +| `ADR-017B-ai-document-classification.md` | AI Document Classification | +| `ADR-018-ai-boundary.md` | AI Boundary Policy | +| `ADR-019-hybrid-identifier-strategy.md` | Hybrid Identifier Strategy | +| `ADR-020-ai-intelligence-integration.md` | AI Intelligence Integration | +| `ADR-021-workflow-context.md` | **Integrated Workflow Context** | +| `README.md` | ADR registry index | + +**08-Tasks/ - Task Documentation (12 files)** +| File | Description | +| ---- | ----------- | +| `Task BE-AI-01.md` | Backend AI Task 01 | +| `Task BE-API-01.md` | Backend API Task 01 | +| `Task BE-DB-01.md` | Backend Database Task 01 | +| `Task BE-ERR-01.md` | Backend Error Handling Task 01 | +| `ADR-021-workflow-context/` | ADR-021 Implementation (4 files) | +| `contracts/` | Contract tasks | +| `data-model.md` | Data model documentation | +| `plan.md` | Implementation plan | +| `quickstart.md` | Quick start guide | +| `README.md` | Tasks section index | + +**001-Transmittals-Circulation/ - Feature Specification (3 files)** +| File | Description | +| ---- | ----------- | +| `plan.md` | Transmittals & Circulation implementation plan | +| `spec.md` | Feature specification | +| `tasks.md` | Task breakdown | + +**88-logs/ - Project Logs (1 file)** +| File | Description | +| ---- | ----------- | +| `CI-error.md` | CI/CD error logs | + +**99-archives/ - Archived Documentation (308 files)** +| File | Description | +| ---- | ----------- | +| `docs/` | Archived documentation | +| `history/` | Project history | +| `skills-backup/` | Skills backup | +| `tasks/` | Archived tasks | + +**feat/ - Feature Development (1 folder)** +| Folder | Description | +| ------ | ----------- | +| `adr-021-integrated-workflow-context/` | ADR-021 feature development | + +**Root Level Files** +| File | Description | +| ---- | ----------- | +| `README.md` | Specifications main index | + +--- ### Schema & Seed Data (v1.8.0) ```bash -# Schema แบ่งเป็น 3 ไฟล์ (ADR-009: ไม่มี TypeORM Migrations) +# Schema 3 files (ADR-009: No TypeORM Migrations) mysql -u root -p lcbp3_dev < specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-01-drop.sql mysql -u root -p lcbp3_dev < specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-02-tables.sql mysql -u root -p lcbp3_dev < specs/03-Data-and-Storage/lcbp3-v1.8.0-schema-03-views-indexes.sql @@ -554,6 +765,14 @@ This project is **Internal Use Only** - ลิขสิทธิ์เป็น ## 🗺️ Roadmap +### ✅ Version 1.8.7 (Apr 2026) — ADR-021 Integration Complete + +- ✅ ADR-021 (Integrated Workflow Context) — Transmittals & Circulation workflow integration +- ✅ IntegratedBanner + WorkflowLifecycle components for real-time workflow status +- ✅ EC-RFA-004, EC-CIRC-001, EC-CIRC-002 workflow validations implemented +- ✅ 19/19 tests passing for new workflow features +- ✅ **Total: 22 ADRs** ครอบคลุมทุก Architectural Decision (ADR-001~021 + ADR-017B) + ### ✅ Version 1.8.5 (Apr 2026) — ADR Documentation Complete - ✅ ADR-003 (API Design Strategy) — Hybrid REST + Action Pattern registered diff --git a/backend/jest.config.js b/backend/jest.config.js index d86582e..e80b692 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -24,6 +24,10 @@ module.exports = { '^.+\\.(t|j)s$': 'ts-jest', }, + // ใช้ V8 built-in coverage แทน babel-plugin-istanbul + // เพื่อหลีกเลี่ยง test-exclude@6.0.0 + minimatch incompatibility + coverageProvider: 'v8', + // Coverage configuration collectCoverageFrom: [ '**/*.(t|j)s', diff --git a/backend/src/common/file-storage/entities/attachment.entity.ts b/backend/src/common/file-storage/entities/attachment.entity.ts index 3168ae0..104acd2 100644 --- a/backend/src/common/file-storage/entities/attachment.entity.ts +++ b/backend/src/common/file-storage/entities/attachment.entity.ts @@ -6,6 +6,7 @@ import { ManyToOne, JoinColumn, } from 'typeorm'; +import { WorkflowHistory } from '../../../modules/workflow-engine/entities/workflow-history.entity'; import { User } from '../../../modules/user/entities/user.entity'; import { UuidBaseEntity } from '../../entities/uuid-base.entity'; import { Exclude } from 'class-transformer'; @@ -46,6 +47,20 @@ export class Attachment extends UuidBaseEntity { @Column({ name: 'reference_date', type: 'date', nullable: true }) referenceDate?: Date; + // ADR-021: FK ไปยัง workflow_histories สำหรับไฟล์แนบประจำ Step + // NULL = ไฟล์แนบหลัก (Main Document), NOT NULL = ไฟล์ประจำ Workflow Step + @Column({ name: 'workflow_history_id', length: 36, nullable: true }) + workflowHistoryId?: string; + + // Lazy relation — ไม่ include ใน default query เพื่อป้องกัน N+1 + @ManyToOne( + () => WorkflowHistory, + (history: WorkflowHistory) => history.attachments, + { nullable: true, onDelete: 'SET NULL', lazy: true } + ) + @JoinColumn({ name: 'workflow_history_id' }) + workflowHistory?: Promise; + @Column({ name: 'uploaded_by_user_id' }) uploadedByUserId!: number; diff --git a/backend/src/common/file-storage/file-storage.controller.spec.ts b/backend/src/common/file-storage/file-storage.controller.spec.ts index c7e4a67..649e921 100644 --- a/backend/src/common/file-storage/file-storage.controller.spec.ts +++ b/backend/src/common/file-storage/file-storage.controller.spec.ts @@ -1,6 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { FileStorageController } from './file-storage.controller'; import { FileStorageService } from './file-storage.service'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RbacGuard } from '../guards/rbac.guard'; import { RequestWithUser } from '../interfaces/request-with-user.interface'; describe('FileStorageController', () => { @@ -12,6 +14,7 @@ describe('FileStorageController', () => { upload: jest.fn(), download: jest.fn(), delete: jest.fn(), + preview: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -22,7 +25,12 @@ describe('FileStorageController', () => { useValue: mockFileStorageService, }, ], - }).compile(); + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .overrideGuard(RbacGuard) + .useValue({ canActivate: () => true }) + .compile(); controller = module.get(FileStorageController); }); diff --git a/backend/src/common/file-storage/file-storage.controller.ts b/backend/src/common/file-storage/file-storage.controller.ts index 61e2b27..cee80e0 100644 --- a/backend/src/common/file-storage/file-storage.controller.ts +++ b/backend/src/common/file-storage/file-storage.controller.ts @@ -20,6 +20,8 @@ import type { Response } from 'express'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileStorageService } from './file-storage.service'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { RbacGuard } from '../guards/rbac.guard'; +import { RequirePermission } from '../decorators/require-permission.decorator'; import type { RequestWithUser } from '../interfaces/request-with-user.interface'; @Controller('files') @@ -73,6 +75,32 @@ export class FileStorageController { return new StreamableFile(stream); } + /** + * ADR-021: Preview Endpoint — GET /files/preview/:publicId + * ส่งไฟล์กลับพร้อม Content-Disposition: inline เพื่อให้ Browser แสดงผลโดยตรง + * ใช้ publicId (UUIDv7) ตาม ADR-019 — ไม่ใช้ INT id + */ + @Get('preview/:publicId') + @UseGuards(RbacGuard) + @RequirePermission('document.view') + async previewFile( + @Param('publicId') publicId: string, + @Res({ passthrough: true }) res: Response + ): Promise { + const { stream, attachment } = + await this.fileStorageService.preview(publicId); + const encodedFilename = encodeURIComponent(attachment.originalFilename); + + res.set({ + 'Content-Type': attachment.mimeType ?? 'application/octet-stream', + // inline = browser แสดงผล, attachment = บังคับดาวน์โหลด + 'Content-Disposition': `inline; filename="${encodedFilename}"; filename*=UTF-8''${encodedFilename}`, + 'Content-Length': String(attachment.fileSize), + }); + + return new StreamableFile(stream); + } + /** * ✅ NEW: Delete Endpoint * DELETE /files/:id diff --git a/backend/src/common/file-storage/file-storage.service.ts b/backend/src/common/file-storage/file-storage.service.ts index b9c4dc4..11e0470 100644 --- a/backend/src/common/file-storage/file-storage.service.ts +++ b/backend/src/common/file-storage/file-storage.service.ts @@ -170,6 +170,31 @@ export class FileStorageService { return committedAttachments; } + /** + * ADR-021: Preview File by publicId (Content-Disposition: inline) + * ดึงไฟล์มาเป็น Stream สำหรับแสดงผลใน Browser โดยตรง (ใช้กับ FilePreviewModal) + */ + async preview( + publicId: string + ): Promise<{ stream: fs.ReadStream; attachment: Attachment }> { + const attachment = await this.attachmentRepository.findOne({ + where: { publicId }, + }); + + if (!attachment) { + throw new NotFoundException(`Attachment not found`); + } + + const filePath = attachment.filePath; + if (!fs.existsSync(filePath)) { + this.logger.error(`Preview file missing on disk: ${filePath}`); + throw new NotFoundException('File not found on server storage'); + } + + const stream = fs.createReadStream(filePath); + return { stream, attachment }; + } + /** * Download File * ดึงไฟล์มาเป็น Stream เพื่อส่งกลับไปให้ Controller diff --git a/backend/src/common/guards/rbac.guard.spec.ts b/backend/src/common/guards/rbac.guard.spec.ts new file mode 100644 index 0000000..f10cd4a --- /dev/null +++ b/backend/src/common/guards/rbac.guard.spec.ts @@ -0,0 +1,304 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RbacGuard } from './rbac.guard'; +import { UserService } from '../../modules/user/user.service'; +import { User } from '../../modules/user/entities/user.entity'; +import { PERMISSIONS_KEY } from '../decorators/require-permission.decorator'; + +describe('RbacGuard', () => { + let guard: RbacGuard; + let reflector: Reflector; + let userService: UserService; + + const createMockExecutionContext = ( + user?: User, + _handlerPermissions?: string[] + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getRequest: () => ({ + user, + }), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as ExecutionContext; + }; + + const createMockUser = (userId: number): User => { + const user = new User(); + user.user_id = userId; + user.username = 'testuser'; + user.email = 'test@example.com'; + return user; + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RbacGuard, + { + provide: Reflector, + useValue: { + getAllAndOverride: jest.fn(), + }, + }, + { + provide: UserService, + useValue: { + getUserPermissions: jest.fn(), + }, + }, + ], + }).compile(); + + guard = module.get(RbacGuard); + reflector = module.get(Reflector); + userService = module.get(UserService); + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + // ========================================================== + // No permissions required + // ========================================================== + describe('when no permissions required', () => { + it('should allow access when no permissions decorator', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + const context = createMockExecutionContext(); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should allow access when empty permissions array', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([]); + const context = createMockExecutionContext(); + + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + }); + + // ========================================================== + // User not in request + // ========================================================== + describe('when user not in request', () => { + it('should throw ForbiddenException when user is undefined', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read']); + const context = createMockExecutionContext(undefined); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException + ); + await expect(guard.canActivate(context)).rejects.toThrow( + 'User not found in request' + ); + }); + }); + + // ========================================================== + // Single permission checks + // ========================================================== + describe('single permission checks', () => { + it('should allow when user has exact permission', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read', 'correspondence.create']); + + const context = createMockExecutionContext(createMockUser(1)); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should deny when user lacks required permission', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.delete']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read', 'correspondence.create']); + + const context = createMockExecutionContext(createMockUser(1)); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException + ); + await expect(guard.canActivate(context)).rejects.toThrow( + 'You do not have permission: correspondence.delete' + ); + }); + }); + + // ========================================================== + // Multiple permissions checks (ALL required) + // ========================================================== + describe('multiple permissions checks (ALL required)', () => { + it('should allow when user has ALL required permissions', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read', 'correspondence.create']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue([ + 'correspondence.read', + 'correspondence.create', + 'correspondence.update', + ]); + + const context = createMockExecutionContext(createMockUser(1)); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should deny when user has only SOME required permissions', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read', 'correspondence.create']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read']); // Missing create + + const context = createMockExecutionContext(createMockUser(1)); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException + ); + }); + + it('should deny when user has none of the required permissions', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read', 'correspondence.create']); + jest.spyOn(userService, 'getUserPermissions').mockResolvedValue([]); + + const context = createMockExecutionContext(createMockUser(1)); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException + ); + }); + }); + + // ========================================================== + // Superadmin bypass (system.manage_all) + // ========================================================== + describe('superadmin bypass (system.manage_all)', () => { + it('should allow superadmin to access any permission', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.delete', 'user.manage']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['system.manage_all']); // Only superadmin permission + + const context = createMockExecutionContext(createMockUser(1)); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should allow superadmin with other permissions', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['rfa.approve']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue([ + 'correspondence.read', + 'system.manage_all', + 'project.view', + ]); + + const context = createMockExecutionContext(createMockUser(1)); + const result = await guard.canActivate(context); + + expect(result).toBe(true); + }); + + it('should still check permissions for non-superadmin', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['admin.only.permission']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read', 'correspondence.create']); + + const context = createMockExecutionContext(createMockUser(1)); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException + ); + }); + }); + + // ========================================================== + // Permission service integration + // ========================================================== + describe('permission service integration', () => { + it('should call getUserPermissions with correct user_id', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read']); + const getPermissionsSpy = jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read']); + + const mockUser = createMockUser(42); + const context = createMockExecutionContext(mockUser); + + await guard.canActivate(context); + + expect(getPermissionsSpy).toHaveBeenCalledWith(42); + }); + + it('should call getUserPermissions only once per request', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read']); + const getPermissionsSpy = jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read']); + + const context = createMockExecutionContext(createMockUser(1)); + + await guard.canActivate(context); + + expect(getPermissionsSpy).toHaveBeenCalledTimes(1); + }); + }); + + // ========================================================== + // Reflector metadata priority + // ========================================================== + describe('reflector metadata priority', () => { + it('should check handler and class metadata', async () => { + const getAllAndOverrideSpy = jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(['correspondence.read']); + jest + .spyOn(userService, 'getUserPermissions') + .mockResolvedValue(['correspondence.read']); + + const context = createMockExecutionContext(createMockUser(1)); + await guard.canActivate(context); + + expect(getAllAndOverrideSpy).toHaveBeenCalledWith(PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + }); + }); +}); diff --git a/backend/src/common/interceptors/audit-log.interceptor.spec.ts b/backend/src/common/interceptors/audit-log.interceptor.spec.ts new file mode 100644 index 0000000..0842b6c --- /dev/null +++ b/backend/src/common/interceptors/audit-log.interceptor.spec.ts @@ -0,0 +1,484 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler, Logger } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuditLogInterceptor } from './audit-log.interceptor'; +import { AuditLog } from '../entities/audit-log.entity'; +import { AuditMetadata } from '../decorators/audit.decorator'; +import { User } from '../../modules/user/entities/user.entity'; +import { of, lastValueFrom } from 'rxjs'; +import { Request } from 'express'; +import type { Socket } from 'net'; + +describe('AuditLogInterceptor', () => { + let interceptor: AuditLogInterceptor; + let reflector: Reflector; + let auditLogRepo: jest.Mocked>; + + const createMockUser = (userId: number): User => { + const user = new User(); + user.user_id = userId; + user.username = 'testuser'; + user.email = 'test@example.com'; + return user; + }; + + const createMockRequest = ( + user?: User, + params: Record = {}, + ip: string = '127.0.0.1', + userAgent: string = 'test-agent' + ): Partial => ({ + user, + params, + ip, + socket: { remoteAddress: ip } as unknown as Socket, + get: jest.fn().mockReturnValue(userAgent), + }); + + const createMockExecutionContext = ( + auditMetadata: AuditMetadata | undefined, + user?: User, + params: Record = {} + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getRequest: () => createMockRequest(user, params), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as ExecutionContext; + }; + + const createMockCallHandler = (data: unknown): CallHandler => ({ + handle: () => of(data), + }); + + beforeEach(async () => { + const mockRepository = { + create: jest.fn().mockReturnValue({}), + save: jest.fn().mockResolvedValue({}), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditLogInterceptor, + { + provide: Reflector, + useValue: { + getAllAndOverride: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AuditLog), + useValue: mockRepository, + }, + ], + }).compile(); + + interceptor = module.get(AuditLogInterceptor); + reflector = module.get(Reflector); + auditLogRepo = module.get(getRepositoryToken(AuditLog)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); + + // ========================================================== + // No audit metadata + // ========================================================== + describe('when no audit metadata', () => { + it('should pass through without creating audit log', async () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + const context = createMockExecutionContext(undefined, undefined); + const callHandler = createMockCallHandler({ id: 'test-uuid' }); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result).toEqual({ id: 'test-uuid' }); + expect(auditLogRepo.create).not.toHaveBeenCalled(); + expect(auditLogRepo.save).not.toHaveBeenCalled(); + }); + }); + + // ========================================================== + // Audit log creation + // ========================================================== + describe('audit log creation', () => { + it('should create audit log with basic metadata', async () => { + const auditMetadata: AuditMetadata = { + action: 'correspondence.create', + entityType: 'correspondence', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({ id: 'new-uuid' }); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + + // Wait for async tap operation + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + action: 'correspondence.create', + entityType: 'correspondence', + severity: 'INFO', + }) + ); + expect(auditLogRepo.save).toHaveBeenCalled(); + }); + + it('should extract entityId from response data.id', async () => { + const auditMetadata: AuditMetadata = { + action: 'correspondence.update', + entityType: 'correspondence', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({ id: 'entity-uuid-123' }); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: 'entity-uuid-123', + }) + ); + }); + + it('should extract entityId from response audit_id', async () => { + const auditMetadata: AuditMetadata = { + action: 'audit.view', + entityType: 'audit_log', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({ audit_id: 'audit-123' }); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: 'audit-123', + }) + ); + }); + + it('should extract entityId from response user_id', async () => { + const auditMetadata: AuditMetadata = { + action: 'user.update', + entityType: 'user', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({ user_id: '42' }); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: '42', + }) + ); + }); + + it('should extract entityId from request params if not in data', async () => { + const auditMetadata: AuditMetadata = { + action: 'correspondence.delete', + entityType: 'correspondence', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user, { + id: 'param-uuid-456', + }); + const callHandler = createMockCallHandler({ success: true }); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: 'param-uuid-456', + }) + ); + }); + }); + + // ========================================================== + // User handling + // ========================================================== + describe('user handling', () => { + it('should handle authenticated user', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(42); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 42, + }) + ); + }); + + it('should handle unauthenticated request (no user)', async () => { + const auditMetadata: AuditMetadata = { + action: 'public.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const context = createMockExecutionContext(auditMetadata, undefined); + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: null, + }) + ); + }); + }); + + // ========================================================== + // Request metadata extraction + // ========================================================== + describe('request metadata extraction', () => { + it('should capture IP address from request', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = { + switchToHttp: () => ({ + getRequest: () => + createMockRequest(user, {}, '192.168.1.100', 'test-agent'), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: '192.168.1.100', + }) + ); + }); + + it('should capture user agent from request', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = { + switchToHttp: () => ({ + getRequest: () => + createMockRequest( + user, + {}, + '127.0.0.1', + 'Mozilla/5.0 Test Browser' + ), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + userAgent: 'Mozilla/5.0 Test Browser', + }) + ); + }); + + it('should use socket.remoteAddress as IP fallback', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + user, + params: {}, + ip: undefined, + socket: { remoteAddress: '10.0.0.1' }, + get: jest.fn().mockReturnValue(''), + }), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: '10.0.0.1', + }) + ); + }); + }); + + // ========================================================== + // Error handling + // ========================================================== + describe('error handling', () => { + it('should log error when audit log save fails', async () => { + const loggerSpy = jest.spyOn(Logger.prototype, 'error'); + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + jest.spyOn(auditLogRepo, 'save').mockRejectedValue(new Error('DB Error')); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to create audit log for test.action') + ); + }); + + it('should not throw when audit log save fails', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + jest.spyOn(auditLogRepo, 'save').mockRejectedValue(new Error('DB Error')); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler({ id: 'test' }); + + // Should not throw + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + expect(result).toEqual({ id: 'test' }); + }); + }); + + // ========================================================== + // Edge cases + // ========================================================== + describe('edge cases', () => { + it('should handle null response data', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler(null); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalled(); + }); + + it('should handle non-object response data', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = createMockExecutionContext(auditMetadata, user); + const callHandler = createMockCallHandler('string response'); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: undefined, + }) + ); + }); + + it('should handle array IP address', async () => { + const auditMetadata: AuditMetadata = { + action: 'test.action', + }; + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(auditMetadata); + + const user = createMockUser(1); + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + user, + params: {}, + ip: ['192.168.1.1', '10.0.0.1'], + socket: {}, + get: jest.fn().mockReturnValue(''), + }), + }), + getHandler: () => ({}), + getClass: () => ({}), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + await lastValueFrom(interceptor.intercept(context, callHandler)); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: '192.168.1.1', // First element of array + }) + ); + }); + }); +}); diff --git a/backend/src/common/interceptors/idempotency.interceptor.spec.ts b/backend/src/common/interceptors/idempotency.interceptor.spec.ts new file mode 100644 index 0000000..3258e6c --- /dev/null +++ b/backend/src/common/interceptors/idempotency.interceptor.spec.ts @@ -0,0 +1,369 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; +import { IdempotencyInterceptor } from './idempotency.interceptor'; +import { of, lastValueFrom } from 'rxjs'; +import { Request } from 'express'; + +describe('IdempotencyInterceptor', () => { + let interceptor: IdempotencyInterceptor; + let cacheManager: jest.Mocked; + + const createMockRequest = ( + method: string, + idempotencyKey?: string + ): Partial => ({ + method, + headers: idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}, + }); + + const createMockExecutionContext = ( + method: string, + idempotencyKey?: string + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getRequest: () => createMockRequest(method, idempotencyKey), + }), + } as ExecutionContext; + }; + + const createMockCallHandler = (data: unknown): CallHandler => ({ + handle: () => of(data), + }); + + beforeEach(async () => { + const mockCache = { + get: jest.fn(), + set: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + IdempotencyInterceptor, + { + provide: CACHE_MANAGER, + useValue: mockCache, + }, + ], + }).compile(); + + interceptor = module.get(IdempotencyInterceptor); + cacheManager = module.get(CACHE_MANAGER); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); + + // ========================================================== + // HTTP Methods - Only mutating methods + // ========================================================== + describe('HTTP method filtering', () => { + it('should process POST requests', async () => { + const context = createMockExecutionContext('POST', 'key-123'); + const callHandler = createMockCallHandler({ id: 'new' }); + cacheManager.get.mockResolvedValue(undefined); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-123'); + }); + + it('should process PUT requests', async () => { + const context = createMockExecutionContext('PUT', 'key-456'); + const callHandler = createMockCallHandler({ id: 'updated' }); + cacheManager.get.mockResolvedValue(undefined); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-456'); + }); + + it('should process PATCH requests', async () => { + const context = createMockExecutionContext('PATCH', 'key-789'); + const callHandler = createMockCallHandler({ id: 'patched' }); + cacheManager.get.mockResolvedValue(undefined); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-789'); + }); + + it('should process DELETE requests', async () => { + const context = createMockExecutionContext('DELETE', 'key-del'); + const callHandler = createMockCallHandler({ success: true }); + cacheManager.get.mockResolvedValue(undefined); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(cacheManager.get).toHaveBeenCalledWith('idempotency:key-del'); + }); + + it('should skip GET requests (no idempotency check)', async () => { + const context = createMockExecutionContext('GET', 'key-get'); + const callHandler = createMockCallHandler({ data: 'test' }); + + const result = await interceptor.intercept(context, callHandler); + const value = await lastValueFrom(result); + + expect(value).toEqual({ data: 'test' }); + expect(cacheManager.get).not.toHaveBeenCalled(); + expect(cacheManager.set).not.toHaveBeenCalled(); + }); + + it('should skip HEAD requests', async () => { + const context = createMockExecutionContext('HEAD', 'key-head'); + const callHandler = createMockCallHandler(undefined); + + await interceptor.intercept(context, callHandler); + + expect(cacheManager.get).not.toHaveBeenCalled(); + }); + + it('should skip OPTIONS requests', async () => { + const context = createMockExecutionContext('OPTIONS', 'key-opt'); + const callHandler = createMockCallHandler(undefined); + + await interceptor.intercept(context, callHandler); + + expect(cacheManager.get).not.toHaveBeenCalled(); + }); + }); + + // ========================================================== + // Idempotency Key Handling + // ========================================================== + describe('idempotency key handling', () => { + it('should skip when no idempotency key header', async () => { + const context = createMockExecutionContext('POST'); + const callHandler = createMockCallHandler({ id: 'test' }); + + const result = await interceptor.intercept(context, callHandler); + const value = await lastValueFrom(result); + + expect(value).toEqual({ id: 'test' }); + expect(cacheManager.get).not.toHaveBeenCalled(); + expect(cacheManager.set).not.toHaveBeenCalled(); + }); + + it('should check cache for existing key', async () => { + const context = createMockExecutionContext('POST', 'existing-key'); + const callHandler = createMockCallHandler({ id: 'new' }); + cacheManager.get.mockResolvedValue(undefined); + + await interceptor.intercept(context, callHandler); + + expect(cacheManager.get).toHaveBeenCalledWith('idempotency:existing-key'); + }); + + it('should return cached response for duplicate key', async () => { + const cachedResponse = { id: 'cached-id', cached: true }; + cacheManager.get.mockResolvedValue(cachedResponse); + + const context = createMockExecutionContext('POST', 'duplicate-key'); + const callHandler = { + handle: jest.fn().mockReturnValue(of({ id: 'new' })), + }; + + const result = await interceptor.intercept(context, callHandler); + const value = await lastValueFrom(result); + + expect(value).toEqual(cachedResponse); + expect(callHandler.handle).not.toHaveBeenCalled(); // Should not call handler + }); + + it('should cache successful response', async () => { + cacheManager.get.mockResolvedValue(undefined); + + const context = createMockExecutionContext('POST', 'cache-key'); + const response = { id: 'new-id', data: 'test' }; + const callHandler = createMockCallHandler(response); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + // Wait for async cache operation + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(cacheManager.set).toHaveBeenCalledWith( + 'idempotency:cache-key', + response, + 86400 * 1000 // 24 hours in ms + ); + }); + }); + + // ========================================================== + // Cache Key Format + // ========================================================== + describe('cache key format', () => { + it('should prefix idempotency keys correctly', async () => { + cacheManager.get.mockResolvedValue(undefined); + + const context = createMockExecutionContext('POST', 'my-key'); + const callHandler = createMockCallHandler({}); + + await interceptor.intercept(context, callHandler); + + expect(cacheManager.get).toHaveBeenCalledWith('idempotency:my-key'); + }); + + it('should handle UUID idempotency keys', async () => { + cacheManager.get.mockResolvedValue(undefined); + + const uuid = '019505a1-7c3e-7000-8000-abc123def456'; + const context = createMockExecutionContext('POST', uuid); + const callHandler = createMockCallHandler({}); + + await interceptor.intercept(context, callHandler); + + expect(cacheManager.get).toHaveBeenCalledWith(`idempotency:${uuid}`); + }); + }); + + // ========================================================== + // Error Handling + // ========================================================== + describe('error handling', () => { + it('should log error when cache set fails', async () => { + const _consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + cacheManager.get.mockResolvedValue(undefined); + cacheManager.set.mockRejectedValue(new Error('Cache Error')); + + const context = createMockExecutionContext('POST', 'error-key'); + const callHandler = createMockCallHandler({ id: 'test' }); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + // Wait for async error handling + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(cacheManager.set).toHaveBeenCalled(); + }); + + it('should not throw when cache get fails', async () => { + cacheManager.get.mockRejectedValue(new Error('Redis down')); + + const context = createMockExecutionContext('POST', 'fail-key'); + const callHandler = { + handle: jest.fn().mockReturnValue(of({ id: 'test' })), + }; + + // Should not throw, let request proceed - handler should still be called + const result = await interceptor.intercept(context, callHandler); + const value = await lastValueFrom(result); + + expect(value).toEqual({ id: 'test' }); + expect(callHandler.handle).toHaveBeenCalled(); + }); + + it('should handle null cached value', async () => { + cacheManager.get.mockResolvedValue(null); + + const context = createMockExecutionContext('POST', 'null-key'); + const callHandler = createMockCallHandler({ id: 'test' }); + + const result = await interceptor.intercept(context, callHandler); + const value = await lastValueFrom(result); + + expect(value).toEqual({ id: 'test' }); + }); + }); + + // ========================================================== + // Cache TTL + // ========================================================== + describe('cache TTL configuration', () => { + it('should cache for 24 hours (86400 seconds)', async () => { + cacheManager.get.mockResolvedValue(undefined); + + const context = createMockExecutionContext('POST', 'ttl-key'); + const callHandler = createMockCallHandler({ id: 'test' }); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(cacheManager.set).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + 86400 * 1000 // 24 hours in milliseconds + ); + }); + }); + + // ========================================================== + // Edge Cases + // ========================================================== + describe('edge cases', () => { + it('should handle empty idempotency key', async () => { + const context = createMockExecutionContext('POST', ''); + const callHandler = createMockCallHandler({ id: 'test' }); + + const result = await interceptor.intercept(context, callHandler); + const value = await lastValueFrom(result); + + // Empty string is falsy, so should pass through + expect(value).toEqual({ id: 'test' }); + }); + + it('should handle complex response objects', async () => { + cacheManager.get.mockResolvedValue(undefined); + + const complexResponse = { + id: 'uuid-123', + nested: { + data: [1, 2, 3], + meta: { count: 3 }, + }, + tags: ['a', 'b', 'c'], + }; + + const context = createMockExecutionContext('POST', 'complex-key'); + const callHandler = createMockCallHandler(complexResponse); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(cacheManager.set).toHaveBeenCalledWith( + 'idempotency:complex-key', + complexResponse, + expect.any(Number) + ); + }); + + it('should handle array responses', async () => { + cacheManager.get.mockResolvedValue(undefined); + + const arrayResponse = [{ id: '1' }, { id: '2' }]; + + const context = createMockExecutionContext('POST', 'array-key'); + const callHandler = createMockCallHandler(arrayResponse); + + const result = await interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(cacheManager.set).toHaveBeenCalledWith( + 'idempotency:array-key', + arrayResponse, + expect.any(Number) + ); + }); + }); +}); diff --git a/backend/src/common/interceptors/idempotency.interceptor.ts b/backend/src/common/interceptors/idempotency.interceptor.ts index a3e23f2..20a0b05 100644 --- a/backend/src/common/interceptors/idempotency.interceptor.ts +++ b/backend/src/common/interceptors/idempotency.interceptor.ts @@ -41,7 +41,19 @@ export class IdempotencyInterceptor implements NestInterceptor { const cacheKey = `idempotency:${idempotencyKey}`; - const cachedResponse = await this.cacheManager.get(cacheKey); + let cachedResponse; + try { + cachedResponse = await this.cacheManager.get(cacheKey); + } catch (err) { + // Log error but proceed with request if cache get fails + const errorMessage = err instanceof Error ? err.stack : String(err); + this.logger.error( + `Failed to get idempotency key ${idempotencyKey} from cache`, + errorMessage + ); + // Proceed with request as if no cached response exists + cachedResponse = undefined; + } if (cachedResponse) { this.logger.warn( diff --git a/backend/src/common/interceptors/performance.interceptor.spec.ts b/backend/src/common/interceptors/performance.interceptor.spec.ts new file mode 100644 index 0000000..43c3886 --- /dev/null +++ b/backend/src/common/interceptors/performance.interceptor.spec.ts @@ -0,0 +1,486 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler, Logger } from '@nestjs/common'; +import { PerformanceInterceptor } from './performance.interceptor'; +import { MetricsService } from '../../modules/monitoring/services/metrics.service'; +import { of, throwError, lastValueFrom } from 'rxjs'; +import { delay } from 'rxjs/operators'; + +describe('PerformanceInterceptor', () => { + let interceptor: PerformanceInterceptor; + let metricsService: jest.Mocked; + + const createMockRequest = (url: string, method: string) => ({ + url, + method, + route: url.startsWith('/api/') + ? { path: url.replace('/api', '') } + : undefined, + }); + + const createMockResponse = (statusCode: number) => ({ + statusCode, + }); + + const createMockExecutionContext = ( + url: string, + method: string, + statusCode: number + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getRequest: () => createMockRequest(url, method), + getResponse: () => createMockResponse(statusCode), + }), + } as ExecutionContext; + }; + + const createMockCallHandler = (data: unknown): CallHandler => ({ + handle: () => of(data), + }); + + beforeEach(async () => { + const mockMetrics = { + httpRequestsTotal: { + inc: jest.fn(), + }, + httpRequestDuration: { + observe: jest.fn(), + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PerformanceInterceptor, + { + provide: MetricsService, + useValue: mockMetrics, + }, + ], + }).compile(); + + interceptor = module.get(PerformanceInterceptor); + metricsService = module.get(MetricsService); + + // Mock Logger to suppress output + jest.spyOn(Logger.prototype, 'log').mockImplementation(); + jest.spyOn(Logger.prototype, 'error').mockImplementation(); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + // Re-initialize metricsService mocks after clearing + metricsService.httpRequestsTotal.inc = jest.fn(); + metricsService.httpRequestDuration.observe = jest.fn(); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); + + // ========================================================== + // Metrics endpoint exclusion + // ========================================================== + describe('endpoint exclusions', () => { + it('should skip /metrics endpoint', () => { + const context = createMockExecutionContext('/metrics', 'GET', 200); + const callHandler = createMockCallHandler({}); + const handleSpy = jest.spyOn(callHandler, 'handle'); + + interceptor.intercept(context, callHandler); + + expect(handleSpy).toHaveBeenCalled(); + expect(metricsService.httpRequestsTotal.inc).not.toHaveBeenCalled(); + }); + + it('should skip /health endpoint', () => { + const context = createMockExecutionContext('/health', 'GET', 200); + const callHandler = createMockCallHandler({ status: 'ok' }); + + interceptor.intercept(context, callHandler); + + expect(metricsService.httpRequestsTotal.inc).not.toHaveBeenCalled(); + }); + + it('should process regular API endpoints', async () => { + const context = createMockExecutionContext( + '/api/correspondences', + 'GET', + 200 + ); + const callHandler = createMockCallHandler({ data: [] }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalled(); + }); + }); + + // ========================================================== + // Metrics recording + // ========================================================== + describe('metrics recording', () => { + it('should increment request counter with correct labels', async () => { + const context = createMockExecutionContext( + '/api/correspondences', + 'GET', + 200 + ); + const callHandler = createMockCallHandler({ data: [] }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith({ + method: 'GET', + route: '/correspondences', + status_code: '200', + }); + }); + + it('should observe request duration with correct labels', async () => { + const context = createMockExecutionContext( + '/api/correspondences', + 'POST', + 201 + ); + const callHandler = createMockCallHandler({ id: 'new-uuid' }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestDuration.observe).toHaveBeenCalledWith( + { + method: 'POST', + route: '/correspondences', + status_code: '201', + }, + expect.any(Number) // Duration in seconds + ); + }); + + it('should use route path when available', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + url: '/api/correspondences/123e4567-e89b-12d3-a456-426614174000', + method: 'GET', + route: { path: '/api/correspondences/:uuid' }, + }), + getResponse: () => ({ statusCode: 200 }), + }), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + route: '/api/correspondences/:uuid', + }) + ); + }); + + it('should fallback to URL when route not available', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + url: '/api/special-endpoint', + method: 'GET', + // No route property + }), + getResponse: () => ({ statusCode: 200 }), + }), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + route: '/api/special-endpoint', + }) + ); + }); + }); + + // ========================================================== + // Error handling + // ========================================================== + describe('error handling', () => { + it('should record metrics for error responses', async () => { + const context = createMockExecutionContext( + '/api/correspondences', + 'POST', + 500 + ); + const callHandler: CallHandler = { + handle: () => throwError(() => ({ status: 500, message: 'Error' })), + }; + + const result = interceptor.intercept(context, callHandler); + + try { + await lastValueFrom(result); + } catch { + // Expected error + } + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith({ + method: 'POST', + route: '/correspondences', + status_code: '500', + }); + }); + + it('should use error status code when available', async () => { + const context = createMockExecutionContext('/api/test', 'GET', 400); + const callHandler: CallHandler = { + handle: () => throwError(() => ({ status: 400 })), + }; + + const result = interceptor.intercept(context, callHandler); + + try { + await lastValueFrom(result); + } catch { + // Expected + } + + expect(metricsService.httpRequestDuration.observe).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Number) + ); + }); + + it('should default to 500 for errors without status', async () => { + const context = createMockExecutionContext('/api/test', 'GET', 500); + const callHandler: CallHandler = { + handle: () => throwError(() => new Error('Unknown error')), + }; + + const result = interceptor.intercept(context, callHandler); + + try { + await lastValueFrom(result); + } catch { + // Expected + } + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + status_code: '500', + }) + ); + }); + }); + + // ========================================================== + // Logging behavior + // ========================================================== + describe('logging behavior', () => { + it('should log slow requests (>200ms)', async () => { + const logSpy = jest.spyOn(Logger.prototype, 'log'); + + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + url: '/api/slow-endpoint', + method: 'GET', + route: { path: '/api/slow-endpoint' }, + }), + getResponse: () => ({ statusCode: 200 }), + }), + } as ExecutionContext; + + const callHandler: CallHandler = { + handle: () => + of({}).pipe( + delay(250) // Simulate slow response >200ms + ), + }; + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(logSpy).toHaveBeenCalled(); + }); + + it('should log error responses (>=400)', async () => { + const logSpy = jest.spyOn(Logger.prototype, 'log'); + const context = createMockExecutionContext('/api/error', 'GET', 400); + const callHandler = createMockCallHandler({ error: 'Bad Request' }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 400, + level: 'warn', + }) + ); + }); + + it('should log server errors with error level (>=500)', async () => { + const logSpy = jest.spyOn(Logger.prototype, 'log'); + const context = createMockExecutionContext('/api/error', 'GET', 500); + const callHandler = createMockCallHandler({ error: 'Server Error' }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(logSpy).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 500, + level: 'error', + }) + ); + }); + + it('should NOT log fast successful requests', async () => { + const logSpy = jest.spyOn(Logger.prototype, 'log'); + const context = createMockExecutionContext('/api/fast', 'GET', 200); + const callHandler = createMockCallHandler({ data: 'quick' }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + // Fast requests (<200ms, status <400) should not be logged + const slowOrErrorLogs = logSpy.mock.calls.filter( + (call) => + (call[0] as { durationMs?: number })?.durationMs !== undefined || + ((call[0] as { statusCode?: number })?.statusCode ?? 0) >= 400 + ); + expect(slowOrErrorLogs.length).toBe(0); + }); + }); + + // ========================================================== + // Duration calculation + // ========================================================== + describe('duration calculation', () => { + it('should calculate duration in seconds', async () => { + const context = createMockExecutionContext('/api/test', 'GET', 200); + const callHandler = createMockCallHandler({}); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + const observeCall = ( + metricsService.httpRequestDuration.observe as jest.Mock + ).mock.calls[0] as unknown[]; + const durationSeconds = observeCall[1] as number; + + expect(durationSeconds).toBeGreaterThanOrEqual(0); + expect(durationSeconds).toBeLessThan(1); // Should be very fast in tests + }); + + it('should log duration in milliseconds', async () => { + const logSpy = jest.spyOn(Logger.prototype, 'log'); + const context = createMockExecutionContext('/api/slow', 'GET', 200); + + const callHandler: CallHandler = { + handle: () => of({}).pipe(delay(250)), // Slow response + }; + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + const logCall = logSpy.mock.calls.find( + (call) => (call[0] as { durationMs?: number })?.durationMs !== undefined + ); + + expect(logCall).toBeDefined(); + if (logCall) { + expect( + (logCall[0] as { durationMs: number }).durationMs + ).toBeGreaterThanOrEqual(200); + } + }); + }); + + // ========================================================== + // Edge cases + // ========================================================== + describe('edge cases', () => { + it('should handle empty URL', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + url: '', + method: 'GET', + }), + getResponse: () => ({ statusCode: 200 }), + }), + } as ExecutionContext; + const callHandler = createMockCallHandler({}); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + route: '', + }) + ); + }); + + it('should handle various HTTP methods', async () => { + const methods = [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', + ]; + + for (const method of methods) { + jest.clearAllMocks(); + // Re-initialize metricsService mocks after clearing + metricsService.httpRequestsTotal.inc = jest.fn(); + metricsService.httpRequestDuration.observe = jest.fn(); + + const context = createMockExecutionContext('/api/test', method, 200); + const callHandler = createMockCallHandler({}); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + method, + }) + ); + } + }); + + it('should handle response with final status code', async () => { + const context = { + switchToHttp: () => ({ + getRequest: () => ({ + url: '/api/test', + method: 'POST', + route: { path: '/api/test' }, + }), + getResponse: () => ({ statusCode: 201 }), // Different from initial + }), + } as ExecutionContext; + const callHandler = createMockCallHandler({ id: 'new' }); + + const result = interceptor.intercept(context, callHandler); + await lastValueFrom(result); + + expect(metricsService.httpRequestsTotal.inc).toHaveBeenCalledWith( + expect.objectContaining({ + status_code: '201', + }) + ); + }); + }); +}); diff --git a/backend/src/common/interceptors/transform.interceptor.spec.ts b/backend/src/common/interceptors/transform.interceptor.spec.ts new file mode 100644 index 0000000..5385df1 --- /dev/null +++ b/backend/src/common/interceptors/transform.interceptor.spec.ts @@ -0,0 +1,387 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { TransformInterceptor, ApiResponse } from './transform.interceptor'; +import { of, lastValueFrom } from 'rxjs'; + +describe('TransformInterceptor', () => { + let interceptor: TransformInterceptor; + + const createMockExecutionContext = ( + statusCode: number = 200 + ): ExecutionContext => { + return { + switchToHttp: () => ({ + getResponse: () => ({ + statusCode, + }), + }), + } as ExecutionContext; + }; + + const createMockCallHandler = (data: unknown): CallHandler => { + return { + handle: () => of(data), + }; + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TransformInterceptor], + }).compile(); + + interceptor = + module.get>(TransformInterceptor); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); + + // ========================================================== + // Standard Response Wrapping + // ========================================================== + describe('standard response wrapping', () => { + it('should wrap simple data in ApiResponse format', async () => { + const data = { id: 'test-uuid', name: 'Test' }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result).toEqual({ + statusCode: 200, + message: 'Success', + data, + }); + }); + + it('should use custom message from data if provided', async () => { + const data = { result: { id: 'test' }, message: 'Custom message' }; + const context = createMockExecutionContext(201); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result).toEqual({ + statusCode: 201, + message: 'Custom message', + data: { id: 'test' }, + }); + }); + + it('should handle 201 Created status', async () => { + const data = { id: 'new-uuid' }; + const context = createMockExecutionContext(201); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.statusCode).toBe(201); + }); + }); + + // ========================================================== + // Paginated Response Handling + // ========================================================== + describe('paginated response handling', () => { + it('should unwrap paginated payload correctly', async () => { + const paginatedData = { + data: [{ id: '1' }, { id: '2' }], + meta: { + total: 100, + page: 1, + limit: 10, + totalPages: 10, + }, + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(paginatedData); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result).toEqual({ + statusCode: 200, + message: 'Success', + data: [{ id: '1' }, { id: '2' }], + meta: { + total: 100, + page: 1, + limit: 10, + totalPages: 10, + }, + }); + }); + + it('should preserve custom message in paginated response', async () => { + const paginatedData = { + data: [{ id: '1' }], + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + message: 'Filtered results', + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(paginatedData); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.message).toBe('Filtered results'); + }); + + it('should handle empty paginated results', async () => { + const paginatedData = { + data: [], + meta: { + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }, + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(paginatedData); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toEqual([]); + expect(result.meta?.total).toBe(0); + }); + + it('should handle paginated response with result field', async () => { + // When data has both result and meta, it should be treated as paginated + const paginatedData = { + data: [{ id: '1' }], + meta: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(paginatedData); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toEqual([{ id: '1' }]); + }); + }); + + // ========================================================== + // Non-Paginated Data with Result Field + // ========================================================== + describe('non-paginated data with result field', () => { + it('should extract result field as data when present', async () => { + const data = { + result: { id: 'extracted', name: 'Extracted Data' }, + otherField: 'ignored', + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toEqual({ id: 'extracted', name: 'Extracted Data' }); + }); + + it('should handle data without result field', async () => { + const data = { id: 'direct', name: 'Direct Data' }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toEqual({ id: 'direct', name: 'Direct Data' }); + }); + }); + + // ========================================================== + // ADR-019: Entity Serialization + // ========================================================== + describe('ADR-019: Entity serialization', () => { + it('should serialize class instances via instanceToPlain', async () => { + // Mock entity with @Exclude decorator + class MockEntity { + id = 1; // Internal ID (should be excluded) + publicId = 'uuid-123'; // Public ID + name = 'Test'; + } + + const entity = new MockEntity(); + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(entity); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + // instanceToPlain should be applied (no @Exclude in this test, so all fields present) + expect(result.data).toBeDefined(); + }); + + it('should handle null data gracefully', async () => { + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(null); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result).toEqual({ + statusCode: 200, + message: 'Success', + data: null, + }); + }); + + it('should handle undefined data gracefully', async () => { + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(undefined); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result).toEqual({ + statusCode: 200, + message: 'Success', + data: undefined, + }); + }); + }); + + // ========================================================== + // Edge Cases + // ========================================================== + describe('edge cases', () => { + it('should handle primitive string data', async () => { + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler('simple string'); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toBe('simple string'); + }); + + it('should handle primitive number data', async () => { + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(42); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toBe(42); + }); + + it('should handle primitive boolean data', async () => { + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(true); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toBe(true); + }); + + it('should handle array data', async () => { + const data = [{ id: '1' }, { id: '2' }]; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toEqual(data); + expect(result.meta).toBeUndefined(); // Arrays are NOT paginated + }); + + it('should handle deeply nested objects', async () => { + const data = { + level1: { + level2: { + level3: { value: 'deep' }, + }, + }, + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(data); + + const result = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.data).toEqual(data); + }); + }); + + // ========================================================== + // Type Safety Tests + // ========================================================== + describe('type safety', () => { + it('should return correct ApiResponse type structure', async () => { + const data = { test: 'data' }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(data); + + const result: ApiResponse = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + // Type checking at runtime + expect(result).toHaveProperty('statusCode'); + expect(result).toHaveProperty('message'); + expect(result).toHaveProperty('data'); + expect(result.statusCode).toBe(200); + }); + + it('should include meta for paginated responses', async () => { + const paginatedData = { + data: [], + meta: { + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }, + }; + const context = createMockExecutionContext(200); + const callHandler = createMockCallHandler(paginatedData); + + const result: ApiResponse = await lastValueFrom( + interceptor.intercept(context, callHandler) + ); + + expect(result.meta).toBeDefined(); + expect(result.meta).toHaveProperty('total'); + expect(result.meta).toHaveProperty('page'); + expect(result.meta).toHaveProperty('limit'); + expect(result.meta).toHaveProperty('totalPages'); + }); + }); +}); diff --git a/backend/src/common/utils/uuid-guard.spec.ts b/backend/src/common/utils/uuid-guard.spec.ts new file mode 100644 index 0000000..68a9f8a --- /dev/null +++ b/backend/src/common/utils/uuid-guard.spec.ts @@ -0,0 +1,145 @@ +import { assertUuid } from './uuid-guard'; + +describe('assertUuid', () => { + // ========================================================== + // Valid UUIDs (should pass) + // ========================================================== + describe('valid UUIDs', () => { + it('should accept valid UUIDv7 format (lowercase)', () => { + const uuid = '019505a1-7c3e-7000-8000-abc123def456'; + expect(assertUuid(uuid)).toBe(uuid); + }); + + it('should accept valid UUIDv7 format (uppercase)', () => { + const uuid = '019505A1-7C3E-7000-8000-ABC123DEF456'; + expect(assertUuid(uuid)).toBe(uuid); + }); + + it('should accept valid UUIDv7 format (mixed case)', () => { + const uuid = '019505a1-7C3E-7000-8000-aBc123DeF456'; + expect(assertUuid(uuid)).toBe(uuid); + }); + + it('should accept valid UUIDv4 format', () => { + const uuid = 'a1b2c3d4-e5f6-4789-abcd-ef0123456789'; + expect(assertUuid(uuid)).toBe(uuid); + }); + + it('should accept valid UUIDv1 format (MariaDB native)', () => { + const uuid = '550e8400-e29b-11d4-a716-446655440000'; + expect(assertUuid(uuid)).toBe(uuid); + }); + + it('should accept UUID with all zeros', () => { + const uuid = '00000000-0000-0000-0000-000000000000'; + expect(assertUuid(uuid)).toBe(uuid); + }); + + it('should accept UUID with all Fs', () => { + const uuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff'; + expect(assertUuid(uuid)).toBe(uuid); + }); + }); + + // ========================================================== + // Invalid UUIDs (should throw) + // ========================================================== + describe('invalid UUIDs', () => { + it('should throw for empty string', () => { + expect(() => assertUuid('')).toThrow('Invalid UUID format: '); + }); + + it('should throw for null (as string)', () => { + expect(() => assertUuid('null')).toThrow('Invalid UUID format: null'); + }); + + it('should throw for undefined (as string)', () => { + expect(() => assertUuid('undefined')).toThrow( + 'Invalid UUID format: undefined' + ); + }); + + it('should throw for numeric string', () => { + expect(() => assertUuid('12345')).toThrow('Invalid UUID format: 12345'); + }); + + it('should throw for string without hyphens', () => { + expect(() => assertUuid('019505a17c3e70008000abc123def456')).toThrow( + 'Invalid UUID format: 019505a17c3e70008000abc123def456' + ); + }); + + it('should throw for UUID with missing segments', () => { + expect(() => assertUuid('019505a1-7c3e-7000-8000')).toThrow( + 'Invalid UUID format: 019505a1-7c3e-7000-8000' + ); + }); + + it('should throw for UUID with extra segments', () => { + expect(() => + assertUuid('019505a1-7c3e-7000-8000-abc123def456-extra') + ).toThrow( + 'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def456-extra' + ); + }); + + it('should throw for UUID with invalid characters', () => { + expect(() => assertUuid('019505a1-7c3e-7000-8000-abc123def45g')).toThrow( + 'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def45g' + ); + }); + + it('should throw for UUID with special characters', () => { + expect(() => assertUuid('019505a1-7c3e-7000-8000-abc123def45!')).toThrow( + 'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def45!' + ); + }); + + it('should throw for whitespace-only string', () => { + expect(() => assertUuid(' ')).toThrow('Invalid UUID format: '); + }); + + it('should throw for UUID with leading whitespace', () => { + expect(() => assertUuid(' 019505a1-7c3e-7000-8000-abc123def456')).toThrow( + 'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def456' + ); + }); + + it('should throw for UUID with trailing whitespace', () => { + expect(() => assertUuid('019505a1-7c3e-7000-8000-abc123def456 ')).toThrow( + 'Invalid UUID format: 019505a1-7c3e-7000-8000-abc123def456 ' + ); + }); + + it('should throw for random text', () => { + expect(() => assertUuid('not-a-valid-uuid-string')).toThrow( + 'Invalid UUID format: not-a-valid-uuid-string' + ); + }); + }); + + // ========================================================== + // ADR-019 Compliance Tests + // ========================================================== + describe('ADR-019 UUID compliance', () => { + it('should NOT use parseInt on UUID (would cause incorrect behavior)', () => { + const uuid = '019505a1-7c3e-7000-8000-abc123def456'; + // ADR-019: UUID must be validated as string — parseInt('019505a1-...') yields 19505 (WRONG) + // assertUuid enforces string-based UUID validation, preventing this data corruption + expect(() => assertUuid(uuid)).not.toThrow(); + }); + + it('should validate UUID as string, not numeric', () => { + // UUIDs that look like numbers should still be validated as strings + const numericLikeUuid = '12345678-1234-1234-1234-123456789abc'; + expect(() => assertUuid(numericLikeUuid)).not.toThrow(); + }); + + it('should return the original UUID string when valid', () => { + const uuid = '019505a1-7c3e-7000-8000-abc123def456'; + const result = assertUuid(uuid); + expect(result).toBe(uuid); + expect(typeof result).toBe('string'); + }); + }); +}); diff --git a/backend/src/modules/circulation/circulation.controller.ts b/backend/src/modules/circulation/circulation.controller.ts index 97f4e69..d6b4bac 100644 --- a/backend/src/modules/circulation/circulation.controller.ts +++ b/backend/src/modules/circulation/circulation.controller.ts @@ -8,13 +8,22 @@ import { ParseIntPipe, UseGuards, Patch, + HttpCode, + HttpStatus, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; - +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiParam, + ApiBody, +} from '@nestjs/swagger'; import { CirculationService } from './circulation.service'; import { CreateCirculationDto } from './dto/create-circulation.dto'; import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto'; import { SearchCirculationDto } from './dto/search-circulation.dto'; +import { ReassignRoutingDto } from './dto/reassign-routing.dto'; +import { ForceCloseCirculationDto } from './dto/force-close-circulation.dto'; import { User } from '../user/entities/user.entity'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; @@ -63,4 +72,43 @@ export class CirculationController { ) { return this.circulationService.updateRoutingStatus(id, updateDto, user); } + + @Patch(':uuid/routing/:routingId/reassign') + @ApiOperation({ + summary: + 'Re-assign routing to new user when assignee is deactivated (EC-CIRC-001)', + }) + @ApiParam({ name: 'uuid', description: 'Circulation publicId' }) + @ApiParam({ name: 'routingId', description: 'CirculationRouting INT id' }) + @ApiBody({ type: ReassignRoutingDto }) + @RequirePermission('circulation.manage') + @Audit('circulation.reassign', 'circulation') + reassignRouting( + @Param('routingId', ParseIntPipe) routingId: number, + @Body() dto: ReassignRoutingDto, + @CurrentUser() user: User + ) { + return this.circulationService.reassignRouting( + routingId, + dto.newAssigneeId, + user + ); + } + + @Post(':uuid/force-close') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Force close a Circulation with mandatory reason (EC-CIRC-002)', + }) + @ApiParam({ name: 'uuid', description: 'Circulation publicId' }) + @ApiBody({ type: ForceCloseCirculationDto }) + @RequirePermission('circulation.manage') + @Audit('circulation.force_close', 'circulation') + forceClose( + @Param('uuid', ParseUuidPipe) uuid: string, + @Body() dto: ForceCloseCirculationDto, + @CurrentUser() user: User + ) { + return this.circulationService.forceClose(uuid, dto.reason, user); + } } diff --git a/backend/src/modules/circulation/circulation.service.spec.ts b/backend/src/modules/circulation/circulation.service.spec.ts new file mode 100644 index 0000000..3eef2ce --- /dev/null +++ b/backend/src/modules/circulation/circulation.service.spec.ts @@ -0,0 +1,249 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { CirculationService } from './circulation.service'; +import { Circulation } from './entities/circulation.entity'; +import { CirculationRouting } from './entities/circulation-routing.entity'; +import { CirculationStatusCode } from './entities/circulation-status-code.entity'; +import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import { UserService } from '../user/user.service'; +import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; +import { + ValidationException, + NotFoundException, +} from '../../common/exceptions'; +import { User } from '../user/entities/user.entity'; + +describe('CirculationService', () => { + let service: CirculationService; + let circulationRepo: { findOne: jest.Mock; save: jest.Mock }; + let routingRepo: { findOne: jest.Mock; save: jest.Mock }; + let dataSource: { createQueryRunner: jest.Mock }; + let uuidResolver: { resolveUserId: jest.Mock }; + let workflowEngine: { getInstanceByEntity: jest.Mock }; + + const mockUser: Partial = { user_id: 1, username: 'admin' }; + + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { save: jest.fn() }, + }; + + beforeEach(async () => { + circulationRepo = { findOne: jest.fn(), save: jest.fn() }; + routingRepo = { findOne: jest.fn(), save: jest.fn() }; + uuidResolver = { resolveUserId: jest.fn() }; + workflowEngine = { getInstanceByEntity: jest.fn() }; + dataSource = { createQueryRunner: jest.fn(() => mockQueryRunner) }; + + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CirculationService, + { provide: getRepositoryToken(Circulation), useValue: circulationRepo }, + { + provide: getRepositoryToken(CirculationRouting), + useValue: routingRepo, + }, + { + provide: getRepositoryToken(CirculationStatusCode), + useValue: { findOne: jest.fn() }, + }, + { provide: DataSource, useValue: dataSource }, + { provide: DocumentNumberingService, useValue: {} }, + { provide: UuidResolverService, useValue: uuidResolver }, + { + provide: UserService, + useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) }, + }, + { provide: WorkflowEngineService, useValue: workflowEngine }, + ], + }).compile(); + + service = module.get(CirculationService); + }); + + describe('reassignRouting() - EC-CIRC-001', () => { + it('reassigns a PENDING routing to a new user by UUID', async () => { + const mockRouting = { + id: 5, + status: 'PENDING', + assignedTo: 10, + circulation: {}, + }; + routingRepo.findOne.mockResolvedValue(mockRouting); + uuidResolver.resolveUserId.mockResolvedValue(99); + routingRepo.save.mockResolvedValue({ ...mockRouting, assignedTo: 99 }); + + const result = await service.reassignRouting( + 5, + 'new-user-uuid', + mockUser as User + ); + + expect(uuidResolver.resolveUserId).toHaveBeenCalledWith('new-user-uuid'); + expect(routingRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ assignedTo: 99 }) + ); + expect(result.assignedTo).toBe(99); + }); + + it('throws ValidationException when routing is not in PENDING status', async () => { + routingRepo.findOne.mockResolvedValue({ + id: 5, + status: 'COMPLETED', + circulation: {}, + }); + + await expect( + service.reassignRouting(5, 'new-user-uuid', mockUser as User) + ).rejects.toThrow(ValidationException); + }); + + it('throws NotFoundException when routing does not exist', async () => { + routingRepo.findOne.mockResolvedValue(null); + + await expect( + service.reassignRouting(999, 'new-user-uuid', mockUser as User) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('forceClose() - EC-CIRC-002', () => { + const uuid = '019circ-0000-7000-8000-000000000001'; + + const buildMockCirculation = () => ({ + id: 100, + publicId: uuid, + circulationNo: 'CIRC-2026-001', + statusCode: 'OPEN', + routings: [ + { id: 1, status: 'PENDING', comments: null, completedAt: null }, + { + id: 2, + status: 'COMPLETED', + comments: 'done', + completedAt: new Date(), + }, + { id: 3, status: 'IN_PROGRESS', comments: null, completedAt: null }, + ], + }); + + beforeEach(() => { + circulationRepo.findOne.mockResolvedValue(buildMockCirculation()); + }); + + it('saves rejected routings and commits the transaction', async () => { + await service.forceClose(uuid, 'Budget cut', mockUser as User); + + expect(mockQueryRunner.manager.save).toHaveBeenCalledTimes(3); + expect(mockQueryRunner.commitTransaction).toHaveBeenCalled(); + }); + + it('returns success=true and affectedRoutings count of 2', async () => { + const result = await service.forceClose( + uuid, + 'Cost savings', + mockUser as User + ); + + expect(result.success).toBe(true); + expect(result.affectedRoutings).toBe(2); + }); + + it('throws ValidationException when reason is an empty string', async () => { + await expect( + service.forceClose(uuid, '', mockUser as User) + ).rejects.toThrow(ValidationException); + }); + + it('throws ValidationException when reason is only whitespace', async () => { + await expect( + service.forceClose(uuid, ' ', mockUser as User) + ).rejects.toThrow(ValidationException); + }); + + it('throws ValidationException when circulation is already COMPLETED', async () => { + circulationRepo.findOne.mockResolvedValue({ + ...buildMockCirculation(), + statusCode: 'COMPLETED', + }); + + await expect( + service.forceClose(uuid, 'Trying to close completed', mockUser as User) + ).rejects.toThrow(ValidationException); + }); + + it('throws ValidationException when circulation is already CANCELLED', async () => { + circulationRepo.findOne.mockResolvedValue({ + ...buildMockCirculation(), + statusCode: 'CANCELLED', + }); + + await expect( + service.forceClose(uuid, 'Already cancelled', mockUser as User) + ).rejects.toThrow(ValidationException); + }); + + it('throws NotFoundException when circulation is not found', async () => { + circulationRepo.findOne.mockResolvedValue(null); + + await expect( + service.forceClose(uuid, 'Not found', mockUser as User) + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('findOneByUuid() - EC-CIRC-003 workflowInstanceId + deadlineDate', () => { + it('exposes workflowInstanceId and deadlineDate when a workflow instance exists', async () => { + circulationRepo.findOne.mockResolvedValue({ + id: 100, + publicId: '019circ-test-uuid', + circulationNo: 'CIRC-001', + subject: 'Test', + statusCode: 'OPEN', + routings: [], + deadlineDate: '2026-04-20', + }); + workflowEngine.getInstanceByEntity.mockResolvedValue({ + id: 'wf-circ-uuid-001', + currentState: 'OPEN', + availableActions: [], + }); + + const result = await service.findOneByUuid('019circ-test-uuid'); + + expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith( + 'circulation', + '100' + ); + expect(result.workflowInstanceId).toBe('wf-circ-uuid-001'); + expect(result.workflowState).toBe('OPEN'); + expect((result as { deadlineDate?: string }).deadlineDate).toBe( + '2026-04-20' + ); + }); + + it('returns empty availableActions and undefined workflowInstanceId in draft state', async () => { + circulationRepo.findOne.mockResolvedValue({ + id: 101, + publicId: '019circ-draft-uuid', + circulationNo: 'CIRC-002', + statusCode: 'DRAFT', + routings: [], + }); + workflowEngine.getInstanceByEntity.mockResolvedValue(null); + + const result = await service.findOneByUuid('019circ-draft-uuid'); + + expect(result.workflowInstanceId).toBeUndefined(); + expect(result.availableActions).toEqual([]); + }); + }); +}); diff --git a/backend/src/modules/circulation/circulation.service.ts b/backend/src/modules/circulation/circulation.service.ts index 89bbd03..9b15505 100644 --- a/backend/src/modules/circulation/circulation.service.ts +++ b/backend/src/modules/circulation/circulation.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { NotFoundException, PermissionException, @@ -16,9 +16,12 @@ import { SearchCirculationDto } from './dto/search-circulation.dto'; import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service'; import { UuidResolverService } from '../../common/services/uuid-resolver.service'; import { UserService } from '../user/user.service'; +import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; @Injectable() export class CirculationService { + private readonly logger = new Logger(CirculationService.name); + private async hasSystemManageAllPermission(userId: number): Promise { const permissions = await this.userService.getUserPermissions(userId); return permissions.includes('system.manage_all'); @@ -32,7 +35,8 @@ export class CirculationService { private numberingService: DocumentNumberingService, private dataSource: DataSource, private uuidResolver: UuidResolverService, - private userService: UserService + private userService: UserService, + private workflowEngine: WorkflowEngineService ) {} async create(createDto: CreateCirculationDto, user: User) { @@ -184,7 +188,115 @@ export class CirculationService { }); if (!circulation) throw new NotFoundException(`Circulation publicId ${publicId} not found`); - return circulation; + + // v1.8.7: ดึง Workflow Instance สำหรับ Circulation นี้ (nullable — ก่อน Submit ไม่มี Instance) + const wfInstance = await this.workflowEngine.getInstanceByEntity( + 'circulation', + circulation.id.toString() + ); + + return { + ...circulation, + workflowInstanceId: wfInstance?.id, + workflowState: wfInstance?.currentState, + availableActions: wfInstance?.availableActions ?? [], + }; + } + + /** + * EC-CIRC-001: Re-assign routing เมื่อ Assignee ถูก Deactivate (v1.8.7) + * ต้องมีสิทธิ์ circulation.manage + */ + async reassignRouting( + routingId: number, + newAssigneePublicId: string, + user: User + ) { + const routing = await this.routingRepo.findOne({ + where: { id: routingId }, + relations: ['circulation'], + }); + if (!routing) + throw new NotFoundException('Circulation Routing', String(routingId)); + + if (routing.status !== 'PENDING') { + throw new ValidationException( + `Routing ID ${routingId} ไม่ได้อยู่ใน PENDING จึงไม่สามารถ Re-assign ได้` + ); + } + + const newAssigneeId = + await this.uuidResolver.resolveUserId(newAssigneePublicId); + routing.assignedTo = newAssigneeId; + const saved = await this.routingRepo.save(routing); + + this.logger.log( + `Circulation routing ${routingId} reassigned to user ${newAssigneeId} by ${user.user_id}` + ); + return saved; + } + + /** + * EC-CIRC-002: Force Close Circulation พร้อม reason บังคับ (v1.8.7) + * ปิด routing ที่ PENDING ทั้งหมด + เปลี่ยน statusCode เป็น CANCELLED + * ต้องมีสิทธิ์ circulation.manage + */ + async forceClose(publicId: string, reason: string, user: User) { + if (!reason || reason.trim().length === 0) { + throw new ValidationException('กรุณาระบุเหตุผลในการปิดใบเวียนแบบบังคับ'); + } + + const circulation = await this.circulationRepo.findOne({ + where: { publicId }, + relations: ['routings'], + }); + if (!circulation) + throw new NotFoundException(`Circulation publicId ${publicId}`); + + if ( + circulation.statusCode === 'COMPLETED' || + circulation.statusCode === 'CANCELLED' + ) { + throw new ValidationException( + `ใบเวียน ${circulation.circulationNo} ปิดไปแล้ว (${circulation.statusCode})` + ); + } + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // ปิด routing ที่ยัง PENDING ทั้งหมด + const pendingRoutings = circulation.routings.filter( + (r) => r.status === 'PENDING' || r.status === 'IN_PROGRESS' + ); + for (const routing of pendingRoutings) { + routing.status = 'REJECTED'; + routing.comments = `Force closed by user ${user.user_id}: ${reason}`; + routing.completedAt = new Date(); + await queryRunner.manager.save(routing); + } + + // อัปเดตสถานะ Circulation เป็น CANCELLED + circulation.statusCode = 'CANCELLED'; + circulation.closedAt = new Date(); + await queryRunner.manager.save(circulation); + + await queryRunner.commitTransaction(); + this.logger.log( + `Circulation ${publicId} force-closed by user ${user.user_id}. Reason: ${reason}` + ); + return { success: true, affectedRoutings: pendingRoutings.length }; + } catch (err) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Force close failed for ${publicId}: ${(err as Error).message}` + ); + throw err; + } finally { + await queryRunner.release(); + } } // ✅ Logic อัปเดตสถานะและปิดงาน diff --git a/backend/src/modules/circulation/dto/force-close-circulation.dto.ts b/backend/src/modules/circulation/dto/force-close-circulation.dto.ts new file mode 100644 index 0000000..1c54e75 --- /dev/null +++ b/backend/src/modules/circulation/dto/force-close-circulation.dto.ts @@ -0,0 +1,10 @@ +import { IsString, IsNotEmpty, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ForceCloseCirculationDto { + @ApiProperty({ description: 'เหตุผลการปิดใบเวียนแบบบังคับ (บังคับกรอก)' }) + @IsString() + @IsNotEmpty() + @MinLength(5) + reason!: string; +} diff --git a/backend/src/modules/circulation/dto/reassign-routing.dto.ts b/backend/src/modules/circulation/dto/reassign-routing.dto.ts new file mode 100644 index 0000000..eeb512a --- /dev/null +++ b/backend/src/modules/circulation/dto/reassign-routing.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ReassignRoutingDto { + @ApiProperty({ + description: 'publicId (UUID) ของผู้ใช้คนใหม่ที่ได้รับมอบหมาย', + }) + @IsUUID('all') + @IsNotEmpty() + newAssigneeId!: string; +} diff --git a/backend/src/modules/circulation/entities/circulation.entity.ts b/backend/src/modules/circulation/entities/circulation.entity.ts index f2c019e..8129b0e 100644 --- a/backend/src/modules/circulation/entities/circulation.entity.ts +++ b/backend/src/modules/circulation/entities/circulation.entity.ts @@ -46,6 +46,9 @@ export class Circulation extends UuidBaseEntity { @Column({ name: 'closed_at', type: 'timestamp', nullable: true }) closedAt?: Date; + @Column({ name: 'deadline_date', type: 'date', nullable: true }) + deadlineDate?: string; + @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/backend/src/modules/transmittal/transmittal.controller.ts b/backend/src/modules/transmittal/transmittal.controller.ts index 1c2f936..01d92c7 100644 --- a/backend/src/modules/transmittal/transmittal.controller.ts +++ b/backend/src/modules/transmittal/transmittal.controller.ts @@ -6,6 +6,8 @@ import { Param, UseGuards, Query, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { TransmittalService } from './transmittal.service'; import { CreateTransmittalDto } from './dto/create-transmittal.dto'; @@ -23,6 +25,7 @@ import { } from '@nestjs/swagger'; import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe'; import { ProjectService } from '../project/project.service'; +import { Audit } from '../../common/decorators/audit.decorator'; @ApiTags('Transmittals') @ApiBearerAuth() @@ -61,11 +64,29 @@ export class TransmittalController { @Get(':uuid') @ApiOperation({ summary: 'Get Transmittal details' }) @ApiParam({ - name: 'publicId', + name: 'uuid', description: 'Transmittal publicId (from correspondences.publicId)', }) @RequirePermission('document.view') findOne(@Param('uuid', ParseUuidPipe) uuid: string) { return this.transmittalService.findOneByUuid(uuid); } + + @Post(':uuid/submit') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Submit Transmittal to Workflow (with EC-RFA-004 validation)', + }) + @ApiParam({ + name: 'uuid', + description: 'Transmittal publicId (from correspondences.publicId)', + }) + @RequirePermission('document.manage') + @Audit('transmittal.submit', 'transmittal') + submit( + @Param('uuid', ParseUuidPipe) uuid: string, + @CurrentUser() user: User + ) { + return this.transmittalService.submit(uuid, user); + } } diff --git a/backend/src/modules/transmittal/transmittal.module.ts b/backend/src/modules/transmittal/transmittal.module.ts index 3013e3d..f2a74db 100644 --- a/backend/src/modules/transmittal/transmittal.module.ts +++ b/backend/src/modules/transmittal/transmittal.module.ts @@ -5,12 +5,14 @@ import { TransmittalItem } from './entities/transmittal-item.entity'; import { Correspondence } from '../correspondence/entities/correspondence.entity'; import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; +import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; import { TransmittalService } from './transmittal.service'; import { TransmittalController } from './transmittal.controller'; import { DocumentNumberingModule } from '../document-numbering/document-numbering.module'; import { ProjectModule } from '../project/project.module'; import { UserModule } from '../user/user.module'; import { SearchModule } from '../search/search.module'; +import { WorkflowEngineModule } from '../workflow-engine/workflow-engine.module'; @Module({ imports: [ @@ -20,11 +22,13 @@ import { SearchModule } from '../search/search.module'; Correspondence, CorrespondenceType, CorrespondenceStatus, + CorrespondenceRevision, ]), DocumentNumberingModule, ProjectModule, UserModule, SearchModule, + WorkflowEngineModule, ], controllers: [TransmittalController], providers: [TransmittalService], diff --git a/backend/src/modules/transmittal/transmittal.service.spec.ts b/backend/src/modules/transmittal/transmittal.service.spec.ts new file mode 100644 index 0000000..329d2a0 --- /dev/null +++ b/backend/src/modules/transmittal/transmittal.service.spec.ts @@ -0,0 +1,261 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { TransmittalService } from './transmittal.service'; +import { Transmittal } from './entities/transmittal.entity'; +import { TransmittalItem } from './entities/transmittal-item.entity'; +import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity'; +import { CorrespondenceStatus } from '../correspondence/entities/correspondence-status.entity'; +import { CorrespondenceRevision } from '../correspondence/entities/correspondence-revision.entity'; +import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service'; +import { UuidResolverService } from '../../common/services/uuid-resolver.service'; +import { UserService } from '../user/user.service'; +import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; +import { + ValidationException, + NotFoundException, +} from '../../common/exceptions'; +import { User } from '../user/entities/user.entity'; + +describe('TransmittalService', () => { + let service: TransmittalService; + let transmittalRepo: { findOne: jest.Mock }; + let revisionRepo: { + findOne: jest.Mock; + createQueryBuilder: jest.Mock; + save: jest.Mock; + }; + let statusRepo: { findOne: jest.Mock }; + let dataSource: { + manager: { findOne: jest.Mock }; + createQueryRunner: jest.Mock; + }; + let workflowEngine: { + getInstanceByEntity: jest.Mock; + createInstance: jest.Mock; + processTransition: jest.Mock; + }; + + const mockUser: Partial = { + user_id: 1, + username: 'testuser', + primaryOrganizationId: 10, + }; + + const mockTransmittal = { + correspondenceId: 99, + items: [{ itemCorrespondenceId: 201 }, { itemCorrespondenceId: 202 }], + }; + + const mockQB = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + + const mockQueryRunner = { + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: { save: jest.fn() }, + }; + + beforeEach(async () => { + transmittalRepo = { findOne: jest.fn() }; + revisionRepo = { + findOne: jest.fn(), + createQueryBuilder: jest.fn(() => mockQB), + save: jest.fn(), + }; + statusRepo = { findOne: jest.fn() }; + dataSource = { + manager: { findOne: jest.fn() }, + createQueryRunner: jest.fn(() => mockQueryRunner), + }; + workflowEngine = { + getInstanceByEntity: jest.fn(), + createInstance: jest.fn(), + processTransition: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransmittalService, + { provide: getRepositoryToken(Transmittal), useValue: transmittalRepo }, + { + provide: getRepositoryToken(TransmittalItem), + useValue: { find: jest.fn() }, + }, + { + provide: getRepositoryToken(CorrespondenceType), + useValue: { findOne: jest.fn() }, + }, + { + provide: getRepositoryToken(CorrespondenceStatus), + useValue: statusRepo, + }, + { + provide: getRepositoryToken(CorrespondenceRevision), + useValue: revisionRepo, + }, + { provide: DataSource, useValue: dataSource }, + { provide: DocumentNumberingService, useValue: {} }, + { provide: UuidResolverService, useValue: {} }, + { + provide: UserService, + useValue: { getUserPermissions: jest.fn().mockResolvedValue([]) }, + }, + { provide: WorkflowEngineService, useValue: workflowEngine }, + ], + }).compile(); + + service = module.get(TransmittalService); + }); + + describe('submit() - EC-RFA-004', () => { + const uuid = '019abc01-0000-7000-8000-000000000001'; + + beforeEach(() => { + dataSource.manager.findOne.mockResolvedValue({ + id: 99, + correspondenceNumber: 'TRN-2026-001', + }); + transmittalRepo.findOne.mockResolvedValue(mockTransmittal); + }); + + it('throws ValidationException when an item correspondence is in DRAFT state (EC-RFA-004)', async () => { + mockQB.getMany.mockResolvedValue([ + { correspondence: { correspondenceNumber: 'RFA-2026-001' } }, + ]); + + await expect(service.submit(uuid, mockUser as User)).rejects.toThrow( + ValidationException + ); + }); + + it('includes the draft document number in the error response', async () => { + mockQB.getMany.mockResolvedValue([ + { correspondence: { correspondenceNumber: 'RFA-2026-001' } }, + ]); + + let thrownError: unknown; + try { + await service.submit(uuid, mockUser as User); + } catch (e) { + thrownError = e; + } + + expect(thrownError).toBeInstanceOf(ValidationException); + const res = (thrownError as ValidationException).getResponse(); + const resStr = typeof res === 'string' ? res : JSON.stringify(res); + expect(resStr).toContain('RFA-2026-001'); + }); + + it('creates a workflow instance when no items are in DRAFT state', async () => { + mockQB.getMany.mockResolvedValue([]); + workflowEngine.createInstance.mockResolvedValue({ + id: 'wf-instance-uuid-001', + }); + workflowEngine.processTransition.mockResolvedValue({ + nextState: 'IN_REVIEW', + }); + revisionRepo.findOne.mockResolvedValue({ + id: 55, + correspondenceId: 99, + isCurrent: true, + statusId: 1, + }); + statusRepo.findOne + .mockResolvedValueOnce({ id: 1, statusCode: 'DRAFT' }) + .mockResolvedValueOnce({ id: 2, statusCode: 'SUBMITTED' }); + + const result = await service.submit(uuid, mockUser as User); + + expect(workflowEngine.createInstance).toHaveBeenCalledWith( + 'TRANSMITTAL_FLOW_V1', + 'transmittal', + '99', + expect.objectContaining({ ownerId: 1 }) + ); + expect(result).toEqual({ + instanceId: 'wf-instance-uuid-001', + currentState: 'IN_REVIEW', + }); + }); + + it('throws NotFoundException when correspondence publicId is not found', async () => { + dataSource.manager.findOne.mockResolvedValue(null); + + await expect(service.submit(uuid, mockUser as User)).rejects.toThrow( + NotFoundException + ); + }); + + it('throws NotFoundException when transmittal record is not found', async () => { + transmittalRepo.findOne.mockResolvedValue(null); + + await expect(service.submit(uuid, mockUser as User)).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe('findOneByUuid() - workflowInstanceId exposure (ADR-021)', () => { + const uuid = '019abc02-0000-7000-8000-000000000002'; + + it('returns workflowInstanceId and workflowState when a workflow instance exists', async () => { + dataSource.manager.findOne.mockResolvedValue({ id: 99 }); + transmittalRepo.findOne.mockResolvedValue({ + correspondenceId: 99, + transmittalNo: 'TRN-001', + subject: 'Test', + correspondence: { + id: 99, + publicId: uuid, + correspondenceNumber: 'TRN-001', + }, + items: [], + }); + workflowEngine.getInstanceByEntity.mockResolvedValue({ + id: 'wf-uuid-123', + currentState: 'IN_REVIEW', + availableActions: ['APPROVE', 'REJECT'], + }); + + const result = await service.findOneByUuid(uuid); + + expect(workflowEngine.getInstanceByEntity).toHaveBeenCalledWith( + 'transmittal', + '99' + ); + expect(result.workflowInstanceId).toBe('wf-uuid-123'); + expect(result.workflowState).toBe('IN_REVIEW'); + expect(result.availableActions).toEqual(['APPROVE', 'REJECT']); + }); + + it('returns undefined workflowInstanceId when no workflow instance exists (Draft state)', async () => { + dataSource.manager.findOne.mockResolvedValue({ id: 99 }); + transmittalRepo.findOne.mockResolvedValue({ + correspondenceId: 99, + transmittalNo: 'TRN-001', + items: [], + correspondence: { + id: 99, + publicId: uuid, + correspondenceNumber: 'TRN-001', + }, + }); + workflowEngine.getInstanceByEntity.mockResolvedValue(null); + + const result = await service.findOneByUuid(uuid); + + expect(result.workflowInstanceId).toBeUndefined(); + expect(result.workflowState).toBeUndefined(); + expect(result.availableActions).toEqual([]); + }); + }); +}); diff --git a/backend/src/modules/transmittal/transmittal.service.ts b/backend/src/modules/transmittal/transmittal.service.ts index 4e1275e..f4ebfb4 100644 --- a/backend/src/modules/transmittal/transmittal.service.ts +++ b/backend/src/modules/transmittal/transmittal.service.ts @@ -23,6 +23,7 @@ import { CorrespondenceStatus } from '../correspondence/entities/correspondence- import { UuidResolverService } from '../../common/services/uuid-resolver.service'; import { CorrespondenceRecipient } from '../correspondence/entities/correspondence-recipient.entity'; import { UserService } from '../user/user.service'; +import { WorkflowEngineService } from '../workflow-engine/workflow-engine.service'; @Injectable() export class TransmittalService { @@ -42,10 +43,13 @@ export class TransmittalService { private typeRepo: Repository, @InjectRepository(CorrespondenceStatus) private statusRepo: Repository, + @InjectRepository(CorrespondenceRevision) + private revisionRepo: Repository, private numberingService: DocumentNumberingService, private dataSource: DataSource, private uuidResolver: UuidResolverService, - private userService: UserService + private userService: UserService, + private workflowEngine: WorkflowEngineService ) {} async create( @@ -192,9 +196,15 @@ export class TransmittalService { /** * ADR-019: Find Transmittal by parent Correspondence publicId (public identifier). - * Resolves correspondence.publicId → internal correspondenceId (INT) + * v1.8.7: Exposes workflowInstanceId, workflowState, availableActions via WorkflowEngineService */ - async findOneByUuid(publicId: string): Promise { + async findOneByUuid(publicId: string): Promise< + Transmittal & { + workflowInstanceId?: string; + workflowState?: string; + availableActions?: string[]; + } + > { const correspondence = await this.dataSource.manager.findOne( Correspondence, { where: { publicId }, select: ['id'] } @@ -204,7 +214,20 @@ export class TransmittalService { `Transmittal with publicId ${publicId} not found` ); } - return this.findOne(correspondence.id); + const transmittal = await this.findOne(correspondence.id); + + // v1.8.7: ดึง Workflow Instance สำหรับ Transmittal นี้ (nullable — Draft ไม่มี Instance) + const wfInstance = await this.workflowEngine.getInstanceByEntity( + 'transmittal', + correspondence.id.toString() + ); + + return { + ...transmittal, + workflowInstanceId: wfInstance?.id, + workflowState: wfInstance?.currentState, + availableActions: wfInstance?.availableActions ?? [], + }; } async findOne(id: number): Promise { @@ -217,6 +240,86 @@ export class TransmittalService { return transmittal; } + /** + * Submit Transmittal — ตรวจสอบ EC-RFA-004 ก่อนเริ่ม Workflow (v1.8.7) + * EC-RFA-004: ทุก item ต้องไม่อยู่ใน DRAFT ก่อน Submit + */ + async submit( + uuid: string, + user: User + ): Promise<{ instanceId: string; currentState: string }> { + const correspondence = await this.dataSource.manager.findOne( + Correspondence, + { where: { publicId: uuid }, select: ['id', 'correspondenceNumber'] } + ); + if (!correspondence) + throw new NotFoundException(`Transmittal publicId ${uuid}`); + + const transmittal = await this.transmittalRepo.findOne({ + where: { correspondenceId: correspondence.id }, + relations: ['items'], + }); + if (!transmittal) throw new NotFoundException('Transmittal', uuid); + + // EC-RFA-004: ตรวจสอบว่า item ทุกชิ้นไม่อยู่ใน DRAFT + if (transmittal.items && transmittal.items.length > 0) { + const itemCorrIds = transmittal.items.map((i) => i.itemCorrespondenceId); + const draftRevisions = await this.revisionRepo + .createQueryBuilder('rev') + .innerJoin('rev.status', 'status') + .where('rev.correspondenceId IN (:...ids)', { ids: itemCorrIds }) + .andWhere('rev.isCurrent = :isCurrent', { isCurrent: true }) + .andWhere('status.statusCode = :code', { code: 'DRAFT' }) + .leftJoinAndSelect('rev.correspondence', 'corr') + .getMany(); + + if (draftRevisions.length > 0) { + const draftDocNo = + draftRevisions[0]?.correspondence?.correspondenceNumber ?? 'Unknown'; + throw new ValidationException( + `RFA ${draftDocNo} ยังอยู่ใน Draft กรุณา Submit ก่อน` + ); + } + } + + // เริ่ม Workflow Instance สำหรับ Transmittal + const statusDraft = await this.statusRepo.findOne({ + where: { statusCode: 'DRAFT' }, + }); + const instance = await this.workflowEngine.createInstance( + 'TRANSMITTAL_FLOW_V1', + 'transmittal', + correspondence.id.toString(), + { ownerId: user.user_id } + ); + + const result = await this.workflowEngine.processTransition( + instance.id, + 'SUBMIT', + user.user_id, + 'Transmittal Submitted' + ); + + // Sync สถานะกลับที่ Correspondence Revision + if (statusDraft) { + const revision = await this.revisionRepo.findOne({ + where: { correspondenceId: correspondence.id, isCurrent: true }, + }); + if (revision) { + const submittedStatus = await this.statusRepo.findOne({ + where: { statusCode: 'SUBMITTED' }, + }); + if (submittedStatus) { + revision.statusId = submittedStatus.id; + await this.revisionRepo.save(revision); + } + } + } + + this.logger.log(`Transmittal ${uuid} submitted — instance ${instance.id}`); + return { instanceId: instance.id, currentState: result.nextState }; + } + async findAll(query: SearchTransmittalDto) { const { page = 1, limit = 20, projectId, search } = query; const skip = ((page ?? 1) - 1) * (limit ?? 20); @@ -239,6 +342,13 @@ export class TransmittalService { }); } + // B3: purpose filter (EC-RFA-004 aligned) + if (query.purpose) { + queryBuilder.andWhere('transmittal.purpose = :purpose', { + purpose: query.purpose, + }); + } + if (search) { queryBuilder.andWhere( '(correspondence.correspondenceNumber LIKE :search OR revision.title LIKE :search)', diff --git a/backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts b/backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts new file mode 100644 index 0000000..f5fe847 --- /dev/null +++ b/backend/src/modules/workflow-engine/dto/workflow-history-item.dto.ts @@ -0,0 +1,19 @@ +// ADR-021: Response DTOs สำหรับ GET /instances/:id/history +export class AttachmentSummaryDto { + publicId!: string; + originalFilename!: string; + mimeType?: string; + fileSize?: number; +} + +export class WorkflowHistoryItemDto { + id!: string; + fromState!: string; + toState!: string; + action!: string; + actionByUserId?: number; + comment?: string; + metadata?: Record; + attachments!: AttachmentSummaryDto[]; + createdAt!: string; +} diff --git a/backend/src/modules/workflow-engine/dto/workflow-transition.dto.ts b/backend/src/modules/workflow-engine/dto/workflow-transition.dto.ts index acaea9a..9e25c51 100644 --- a/backend/src/modules/workflow-engine/dto/workflow-transition.dto.ts +++ b/backend/src/modules/workflow-engine/dto/workflow-transition.dto.ts @@ -1,7 +1,15 @@ // File: src/modules/workflow-engine/dto/workflow-transition.dto.ts import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; export class WorkflowTransitionDto { @ApiProperty({ @@ -27,4 +35,16 @@ export class WorkflowTransitionDto { @IsObject() @IsOptional() payload?: Record; + + @ApiPropertyOptional({ + description: + 'รายการ publicId ของไฟล์แนบ (ต้องอัปโหลดผ่าน Two-Phase ก่อน — ADR-016)', + example: ['019505a1-7c3e-7000-8000-abc123def456'], + type: [String], + }) + @IsArray() + @IsUUID('all', { each: true }) + @ArrayMaxSize(20) + @IsOptional() + attachmentPublicIds?: string[]; } diff --git a/backend/src/modules/workflow-engine/entities/workflow-history.entity.ts b/backend/src/modules/workflow-engine/entities/workflow-history.entity.ts index 2c62cfc..d485396 100644 --- a/backend/src/modules/workflow-engine/entities/workflow-history.entity.ts +++ b/backend/src/modules/workflow-engine/entities/workflow-history.entity.ts @@ -7,8 +7,10 @@ import { Index, JoinColumn, ManyToOne, + OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { Attachment } from '../../../common/file-storage/entities/attachment.entity'; import { WorkflowInstance } from './workflow-instance.entity'; /** @@ -58,4 +60,12 @@ export class WorkflowHistory { @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; + + // ADR-021: ไฟล์แนบที่อัปโหลดพร้อมขั้นตอนนี้ — Lazy โหลดเฉพาะเมื่อต้องการ (ป้องกัน N+1) + @OneToMany( + () => Attachment, + (attachment: Attachment) => attachment.workflowHistory, + { lazy: true } + ) + attachments?: Promise; } diff --git a/backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts b/backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts new file mode 100644 index 0000000..77c067c --- /dev/null +++ b/backend/src/modules/workflow-engine/guards/workflow-transition.guard.ts @@ -0,0 +1,86 @@ +// File: src/modules/workflow-engine/guards/workflow-transition.guard.ts +// Guard ตรวจสอบสิทธิ์ 4-Level RBAC สำหรับ Workflow Transition ตาม ADR-021 §6 + +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { WorkflowInstance } from '../entities/workflow-instance.entity'; +import { UserService } from '../../../modules/user/user.service'; +import type { RequestWithUser } from '../../../common/interfaces/request-with-user.interface'; + +/** + * WorkflowTransitionGuard — ตรวจสอบสิทธิ์ 4 ระดับก่อนอนุญาตให้เปลี่ยนสถานะ Workflow + * + * Level 1: system.manage_all (Superadmin) → ผ่านทันที + * Level 2: organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร → ผ่าน + * Level 3: Assigned Handler (context.assignedUserId === req.user.user_id) → ผ่าน + * Level 4: ผู้ใช้ทั่วไป → ForbiddenException + */ +@Injectable() +export class WorkflowTransitionGuard implements CanActivate { + private readonly logger = new Logger(WorkflowTransitionGuard.name); + + constructor( + @InjectRepository(WorkflowInstance) + private readonly instanceRepo: Repository, + private readonly userService: UserService + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const instanceId = request.params['id']; + const user = request.user; + + // ดึงสิทธิ์ทั้งหมดของ User จาก DB (ตาม pattern เดียวกับ RbacGuard) + const userPermissions = await this.userService.getUserPermissions( + user.user_id + ); + + // Level 1: Superadmin — ผ่านทุกการตรวจสอบ + if (userPermissions.includes('system.manage_all')) { + return true; + } + + // ดึง Instance เพื่อตรวจสอบ Context + const instance = await this.instanceRepo.findOne({ + where: { id: instanceId }, + }); + + if (!instance) { + throw new NotFoundException('Workflow Instance', instanceId); + } + + // Level 2: Org Admin — organization.manage_users + สังกัดองค์กรเดียวกับเอกสาร + const docOrgId = instance.context?.organizationId as number | undefined; + if ( + userPermissions.includes('organization.manage_users') && + docOrgId !== undefined && + user.primaryOrganizationId === docOrgId + ) { + return true; + } + + // Level 3: Assigned Handler — User นี้ถูก Assign มาให้ทำ Step นี้โดยตรง + const assignedUserId = instance.context?.assignedUserId as + | number + | undefined; + if (assignedUserId !== undefined && user.user_id === assignedUserId) { + return true; + } + + this.logger.warn( + `Unauthorized transition attempt: User ${user.user_id} on Instance ${instanceId}` + ); + throw new ForbiddenException({ + userMessage: 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', + recoveryAction: 'ติดต่อผู้รับผิดชอบหรือ Admin หากคิดว่านี่เป็นข้อผิดพลาด', + }); + } +} diff --git a/backend/src/modules/workflow-engine/workflow-engine.controller.ts b/backend/src/modules/workflow-engine/workflow-engine.controller.ts index 8d44a15..1fa7ae2 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.controller.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.controller.ts @@ -1,15 +1,20 @@ // File: src/modules/workflow-engine/workflow-engine.controller.ts import { + BadRequestException, Body, Controller, Get, + Headers, + Inject, Param, Patch, Post, Request, UseGuards, } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import type { Cache } from 'cache-manager'; import { ApiBearerAuth, ApiOperation, @@ -27,10 +32,11 @@ import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto'; import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto'; import { WorkflowTransitionDto } from './dto/workflow-transition.dto'; -// Guards & Decorators (อ้างอิงตามโครงสร้าง src/common ในแผนงาน) +// Guards & Decorators import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { RbacGuard } from '../../common/guards/rbac.guard'; +import { WorkflowTransitionGuard } from './guards/workflow-transition.guard'; import type { RequestWithUser } from '../../common/interfaces/request-with-user.interface'; @ApiTags('Workflow Engine') @@ -38,7 +44,10 @@ import type { RequestWithUser } from '../../common/interfaces/request-with-user. @Controller('workflow-engine') @UseGuards(JwtAuthGuard, RbacGuard) // บังคับ Login และตรวจสอบสิทธิ์ทุก Request export class WorkflowEngineController { - constructor(private readonly workflowService: WorkflowEngineService) {} + constructor( + private readonly workflowService: WorkflowEngineService, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache + ) {} // ================================================================= // Definition Management (Admin / Developer) @@ -89,25 +98,56 @@ export class WorkflowEngineController { // ================================================================= @Post('instances/:id/transition') - @ApiOperation({ summary: 'สั่งเปลี่ยนสถานะเอกสาร (User Action)' }) + @ApiOperation({ + summary: + 'สั่งเปลี่ยนสถานะเอกสาร (User Action) — ADR-021: 4-Level RBAC + Idempotency', + }) @ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' }) - // Permission จะถูกตรวจสอบ Dynamic ภายใน Service ตาม State ของ Workflow แต่ขั้นต้นต้องมีสิทธิ์ทำงาน Workflow - @RequirePermission('workflow.action_review') + // ADR-021: แทนที่ @RequirePermission สามัญใช้ WorkflowTransitionGuard (4-Level RBAC เต็มรูปแบบ) + @UseGuards(WorkflowTransitionGuard) async processTransition( @Param('id') instanceId: string, @Body() dto: WorkflowTransitionDto, - @Request() req: RequestWithUser + @Request() req: RequestWithUser, + @Headers('Idempotency-Key') idempotencyKey: string ) { - // ดึง User ID จาก Token (req.user มาจาก JwtStrategy) + // ADR-016: Idempotency-Key ต้องมีทุก Request + if (!idempotencyKey) { + throw new BadRequestException('Idempotency-Key header is required'); + } + + // ตรวจ Redis ว่า Request นี้ถูกส่งมาแล้วหรือไม่ + const cacheKey = `idempotency:wf:${idempotencyKey}`; + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + return cached; // คืนผลเดิม (Idempotent Response) + } + const userId = req.user?.user_id; - return this.workflowService.processTransition( + const result = await this.workflowService.processTransition( instanceId, dto.action, userId, dto.comment, - dto.payload + dto.payload, + dto.attachmentPublicIds // ADR-021: step-specific attachments ); + + // เก็บใน Redis 24 ชั่วโมง (86400 วินาที = 86400000 ms ใน cache-manager v7) + await this.cacheManager.set(cacheKey, result, 86_400_000); + + return result; + } + + @Get('instances/:id/history') + @ApiOperation({ + summary: 'ดึงประวัติ Workflow พร้อมไฟล์แนบประจำแต่ละ Step (ADR-021)', + }) + @ApiParam({ name: 'id', description: 'Workflow Instance ID (UUID)' }) + @RequirePermission('document.view') + async getHistory(@Param('id') instanceId: string) { + return this.workflowService.getHistoryWithAttachments(instanceId); } @Get('instances/:id/actions') diff --git a/backend/src/modules/workflow-engine/workflow-engine.module.ts b/backend/src/modules/workflow-engine/workflow-engine.module.ts index f766b67..1b80dac 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.module.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.module.ts @@ -7,26 +7,37 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkflowDefinition } from './entities/workflow-definition.entity'; import { WorkflowHistory } from './entities/workflow-history.entity'; import { WorkflowInstance } from './entities/workflow-instance.entity'; +import { Attachment } from '../../common/file-storage/entities/attachment.entity'; // Services import { WorkflowDslService } from './workflow-dsl.service'; import { WorkflowEngineService } from './workflow-engine.service'; import { WorkflowEventService } from './workflow-event.service'; // [NEW] +// Guards +import { WorkflowTransitionGuard } from './guards/workflow-transition.guard'; + // Controllers import { UserModule } from '../user/user.module'; import { WorkflowEngineController } from './workflow-engine.controller'; + @Module({ imports: [ TypeOrmModule.forFeature([ WorkflowDefinition, WorkflowInstance, WorkflowHistory, + Attachment, // ADR-021: ใช้ link attachments ประจำ Step ]), UserModule, ], controllers: [WorkflowEngineController], - providers: [WorkflowEngineService, WorkflowDslService, WorkflowEventService], + providers: [ + WorkflowEngineService, + WorkflowDslService, + WorkflowEventService, + WorkflowTransitionGuard, + ], exports: [WorkflowEngineService], // Export Service ให้ Module อื่น (Correspondence, RFA) เรียกใช้ }) export class WorkflowEngineModule {} diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts b/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts index 3ebc7c8..c9a976d 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.service.spec.ts @@ -8,6 +8,7 @@ import { WorkflowStatus, } from './entities/workflow-instance.entity'; import { WorkflowHistory } from './entities/workflow-history.entity'; +import { Attachment } from '../../common/file-storage/entities/attachment.entity'; import { WorkflowDslService } from './workflow-dsl.service'; import { WorkflowEventService } from './workflow-event.service'; import { NotFoundException } from '../../common/exceptions'; @@ -30,6 +31,7 @@ describe('WorkflowEngineService', () => { manager: { findOne: jest.fn(), save: jest.fn(), + update: jest.fn(), }, }; @@ -81,6 +83,14 @@ describe('WorkflowEngineService', () => { useValue: { create: jest.fn(), save: jest.fn(), + find: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Attachment), + useValue: { + find: jest.fn(), + update: jest.fn(), }, }, { provide: WorkflowDslService, useValue: mockDslService }, diff --git a/backend/src/modules/workflow-engine/workflow-engine.service.ts b/backend/src/modules/workflow-engine/workflow-engine.service.ts index 11ebff3..8cebc52 100644 --- a/backend/src/modules/workflow-engine/workflow-engine.service.ts +++ b/backend/src/modules/workflow-engine/workflow-engine.service.ts @@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { NotFoundException, WorkflowException } from '../../common/exceptions'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; // Entities import { WorkflowDefinition } from './entities/workflow-definition.entity'; import { WorkflowHistory } from './entities/workflow-history.entity'; @@ -11,11 +11,13 @@ import { WorkflowInstance, WorkflowStatus, } from './entities/workflow-instance.entity'; +import { Attachment } from '../../common/file-storage/entities/attachment.entity'; // Services & Interfaces import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto'; import { EvaluateWorkflowDto } from './dto/evaluate-workflow.dto'; import { UpdateWorkflowDefinitionDto } from './dto/update-workflow-definition.dto'; +import { WorkflowHistoryItemDto } from './dto/workflow-history-item.dto'; import { CompiledWorkflow, RawEvent, @@ -48,6 +50,9 @@ export class WorkflowEngineService { private readonly instanceRepo: Repository, @InjectRepository(WorkflowHistory) private readonly historyRepo: Repository, + // ADR-021: Repository สำหรับ Link Attachments ประจำ Step + @InjectRepository(Attachment) + private readonly attachmentRepo: Repository, private readonly dslService: WorkflowDslService, private readonly eventService: WorkflowEventService, // [NEW] Inject Service private readonly dataSource: DataSource // ใช้สำหรับ Transaction @@ -243,6 +248,42 @@ export class WorkflowEngineService { return instance; } + /** + * ค้นหา Workflow Instance จาก entityType + entityId (ADR-021 / v1.8.7) + * ใช้โดย TransmittalService และ CirculationService เพื่อ expose workflowInstanceId ใน response + * คืนค่า null ถ้าไม่มี Instance (เช่น เอกสาร Draft ที่ยังไม่เริ่ม Workflow) + */ + async getInstanceByEntity( + entityType: string, + entityId: string + ): Promise<{ + id: string; + currentState: string; + availableActions: string[]; + } | null> { + const instance = await this.instanceRepo.findOne({ + where: { entityType, entityId, status: WorkflowStatus.ACTIVE }, + relations: ['definition'], + order: { createdAt: 'DESC' }, + }); + + if (!instance) return null; + + const compiled = instance.definition?.compiled as unknown as + | CompiledWorkflow + | undefined; + const stateConfig = compiled?.states?.[instance.currentState]; + const availableActions = stateConfig?.transitions + ? Object.keys(stateConfig.transitions) + : []; + + return { + id: instance.id, + currentState: instance.currentState, + availableActions, + }; + } + /** * ดำเนินการเปลี่ยนสถานะ (Transition) ของ Instance จริงแบบ Transactional */ @@ -251,7 +292,9 @@ export class WorkflowEngineService { action: string, userId: number, comment?: string, - payload: Record = {} + payload: Record = {}, + // ADR-021: publicIds ของไฟล์แนบประจำ Step นี้ (Two-Phase upload ก่อนแล้ว) + attachmentPublicIds?: string[] ) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -323,6 +366,15 @@ export class WorkflowEngineService { }); await queryRunner.manager.save(history); + // ADR-021: ผูกไฟล์แนบประจำ Step นี้ (ทำในตัว Transaction เดียวกัน) + if (attachmentPublicIds && attachmentPublicIds.length > 0) { + await queryRunner.manager.update( + Attachment, + { publicId: In(attachmentPublicIds) }, + { workflowHistoryId: history.id } + ); + } + await queryRunner.commitTransaction(); // [NEW] เก็บค่าไว้ Dispatch หลัง Commit @@ -380,6 +432,66 @@ export class WorkflowEngineService { ); } + // ================================================================= + // [PART 2.5] ADR-021: Workflow History with Step Attachments + // ================================================================= + + /** + * ดึงประวัติ Workflow พร้อมไฟล์แนบประจำแต่ละ Step (2-query, ไม่มี N+1) + * GET /instances/:id/history + */ + async getHistoryWithAttachments( + instanceId: string + ): Promise { + const histories = await this.historyRepo.find({ + where: { instanceId }, + order: { createdAt: 'ASC' }, + }); + + if (histories.length === 0) return []; + + // Batch-load attachments ครั้งเดียวเพื่อป้องกัน N+1 + const historyIds = histories.map((h) => h.id); + const attachments = await this.attachmentRepo.find({ + where: { workflowHistoryId: In(historyIds) }, + select: [ + 'publicId', + 'originalFilename', + 'mimeType', + 'fileSize', + 'workflowHistoryId', + ], + }); + + // Group attachments ตาม workflowHistoryId + const attByHistoryId = attachments.reduce>( + (acc, att) => { + const key = att.workflowHistoryId!; + if (!acc[key]) acc[key] = []; + acc[key].push(att); + return acc; + }, + {} + ); + + return histories.map((h) => ({ + id: h.id, + fromState: h.fromState, + toState: h.toState, + action: h.action, + actionByUserId: h.actionByUserId, + comment: h.comment, + metadata: h.metadata, + attachments: (attByHistoryId[h.id] ?? []).map((att) => ({ + publicId: att.publicId, + originalFilename: att.originalFilename, + mimeType: att.mimeType, + fileSize: att.fileSize, + })), + createdAt: h.createdAt.toISOString(), + })); + } + // ================================================================= // [PART 3] Legacy Support (Backward Compatibility) // รักษา Logic เดิมไว้เพื่อให้ Module อื่น (Correspondence/RFA) ทำงานต่อได้ diff --git a/frontend/app/(dashboard)/circulation/[uuid]/page.tsx b/frontend/app/(dashboard)/circulation/[uuid]/page.tsx index 9333bef..7300252 100644 --- a/frontend/app/(dashboard)/circulation/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/circulation/[uuid]/page.tsx @@ -1,17 +1,31 @@ 'use client'; +import { useState } from 'react'; import { useParams } from 'next/navigation'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { circulationService } from '@/lib/services/circulation.service'; -import { Circulation, UpdateCirculationRoutingDto } from '@/types/circulation'; +import { UpdateCirculationRoutingDto } from '@/types/circulation'; +import { useCirculation, circulationKeys } from '@/hooks/use-circulation'; +import { useWorkflowHistory } from '@/hooks/use-workflow-history'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { ArrowLeft, RefreshCw, CheckCircle2 } from 'lucide-react'; +import { ArrowLeft, RefreshCw, CheckCircle2, AlertCircle } from 'lucide-react'; import Link from 'next/link'; -import { format } from 'date-fns'; +import { format, isPast, addDays, parseISO } from 'date-fns'; import { toast } from 'sonner'; +import { IntegratedBanner } from '@/components/workflow/integrated-banner'; +import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +/** + * EC-CIRC-003: ตรวจสอบว่า deadline เลยกำหนดแล้วหรือไม่ (overdue = วันถัดไปหลัง deadline + 1 วัน) + */ +function isOverdue(deadlineDate?: string): boolean { + if (!deadlineDate) return false; + return isPast(addDays(parseISO(deadlineDate), 1)); +} /** * Get initials from name @@ -44,23 +58,22 @@ export default function CirculationDetailPage() { const params = useParams(); const queryClient = useQueryClient(); const uuid = params.uuid as string; + const [pendingAttachmentIds, setPendingAttachmentIds] = useState([]); + + const { circulation, isLoading, error } = useCirculation(uuid); const { - data: circulation, - isLoading, - error, - } = useQuery({ - queryKey: ['circulation', uuid], - queryFn: () => circulationService.getByUuid(uuid), - enabled: !!uuid, - }); + data: workflowHistory, + isLoading: historyLoading, + error: historyError, + } = useWorkflowHistory(circulation?.workflowInstanceId); const completeMutation = useMutation({ mutationFn: ({ routingId, data }: { routingId: number; data: UpdateCirculationRoutingDto }) => circulationService.updateRouting(routingId, data), onSuccess: () => { toast.success('Task completed successfully'); - queryClient.invalidateQueries({ queryKey: ['circulation', uuid] }); + queryClient.invalidateQueries({ queryKey: circulationKeys.detail(uuid) }); }, onError: () => { toast.error('Failed to update task status'); @@ -99,23 +112,37 @@ export default function CirculationDetailPage() { } return ( -
- {/* Header */} +
+ {/* ADR-021: Integrated Banner — wired with live workflow data (v1.8.7) */} + + + {/* Navigation Header */}
-
- - - -
-

{circulation.circulationNo}

-

{circulation.subject}

-
-
- {circulation.statusCode} + + +
+ {/* Tabs — Details / Workflow */} + + + รายละเอียด + Workflow + + + {/* Info Card */} @@ -150,6 +177,20 @@ export default function CirculationDetailPage() { )} + {circulation.deadlineDate && ( +
+

Deadline

+

+ {format(parseISO(circulation.deadlineDate), 'dd MMM yyyy')} + {isOverdue(circulation.deadlineDate) && ( + + + Overdue + + )} +

+
+ )}
@@ -203,6 +244,18 @@ export default function CirculationDetailPage() { )} +
+ + {/* ADR-021: WorkflowLifecycle — wired with history data (v1.8.7) */} + + +
); } diff --git a/frontend/app/(dashboard)/correspondences/[uuid]/page.tsx b/frontend/app/(dashboard)/correspondences/[uuid]/page.tsx index c9ff69c..d7b2c28 100644 --- a/frontend/app/(dashboard)/correspondences/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/correspondences/[uuid]/page.tsx @@ -1,9 +1,17 @@ 'use client'; +import { useState } from 'react'; import { CorrespondenceDetail } from '@/components/correspondences/detail'; -import { useCorrespondence } from '@/hooks/use-correspondence'; +import { IntegratedBanner } from '@/components/workflow/integrated-banner'; +import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle'; +import { FilePreviewModal } from '@/components/common/file-preview-modal'; +import { WorkflowErrorBoundary } from '@/components/common/workflow-error-boundary'; +import { useCorrespondence, useWorkflowHistory } from '@/hooks/use-correspondence'; import { Loader2 } from 'lucide-react'; import { useParams, useSearchParams } from 'next/navigation'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { Correspondence } from '@/types/correspondence'; +import type { WorkflowAttachmentSummary } from '@/types/workflow'; export default function CorrespondenceDetailPage() { const params = useParams(); @@ -11,7 +19,23 @@ export default function CorrespondenceDetailPage() { const uuid = (params?.uuid as string) ?? ''; const selectedRevisionId = searchParams.get('revId') ?? undefined; + // Hooks ทั้งหมดต้องเรียกก่อน early return (Rules of Hooks) const { data: correspondence, isLoading, isError } = useCorrespondence(uuid); + const corrData = correspondence as Correspondence | undefined; + + // ADR-021: ดึงประวัติ Workflow (disabled อัตโนมัติถ้าไม่มี workflowInstanceId) + const { data: wfHistory, isLoading: wfLoading, error: wfError } = useWorkflowHistory( + corrData?.workflowInstanceId + ); + + // ADR-021 US4: state สำหรับ FilePreviewModal + const [previewFile, setPreviewFile] = useState(null); + // ADR-021 T029: publicIds ของไฟล์ที่อัปโหลดใน WorkflowLifecycle Upload Zone + const [pendingAttachmentIds, setPendingAttachmentIds] = useState([]); + // ADR-021 T041: ติดตาม publicIds ที่ Storage แจ้ง 404 + const [unavailableIds, setUnavailableIds] = useState([]); + const handleUnavailable = (publicId: string) => + setUnavailableIds((prev) => [...new Set([...prev, publicId])]); if (!uuid) { return ( @@ -38,5 +62,58 @@ export default function CorrespondenceDetailPage() { ); } - return ; + // ดึง Current Revision สำหรับแสดงใน Banner + const currentRevision = corrData!.revisions?.find((r) => r.isCurrent) ?? corrData!.revisions?.[0]; + const docNo = corrData!.correspondenceNumber ?? ''; + const subject = currentRevision?.subject ?? ''; + const status = currentRevision?.status?.statusCode ?? ''; + + return ( +
+ {/* ADR-021: Integrated Banner — เลขเอกสาร + สถานะ + ปุ่ม Action */} + + + {/* Tabs — Details / Workflow (WorkflowLifecycle ถูกเพิ่มใน T020) */} + + + รายละเอียด + Workflow + + + + + + + + + + + + {/* ADR-021 US4: File Preview Modal */} + + setPreviewFile(null)} + onUnavailable={handleUnavailable} + /> + +
+ ); } diff --git a/frontend/app/(dashboard)/rfas/[uuid]/page.tsx b/frontend/app/(dashboard)/rfas/[uuid]/page.tsx index fd5399b..de9f096 100644 --- a/frontend/app/(dashboard)/rfas/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/rfas/[uuid]/page.tsx @@ -1,17 +1,42 @@ 'use client'; +import { useState } from 'react'; import { RFADetail } from '@/components/rfas/detail'; +import { IntegratedBanner } from '@/components/workflow/integrated-banner'; +import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle'; +import { FilePreviewModal } from '@/components/common/file-preview-modal'; +import { WorkflowErrorBoundary } from '@/components/common/workflow-error-boundary'; import { notFound, useParams } from 'next/navigation'; -import { useRFA } from '@/hooks/use-rfa'; +import { useRFA, useWorkflowHistory } from '@/hooks/use-rfa'; import { Loader2 } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import type { RFA } from '@/types/rfa'; +import type { WorkflowAttachmentSummary } from '@/types/workflow'; export default function RFADetailPage() { const { uuid } = useParams(); + const uuidStr = uuid ? String(uuid) : ''; + + // Hooks ทั้งหมดต้องเรียกก่อน early return (Rules of Hooks) + const { data: rfa, isLoading, isError } = useRFA(uuidStr); + const rfaData = rfa as RFA | undefined; + + // ADR-021: ดึงประวัติ Workflow (disabled อัตโนมัติถ้าไม่มี workflowInstanceId) + const { data: wfHistory, isLoading: wfLoading, error: wfError } = useWorkflowHistory( + rfaData?.workflowInstanceId + ); + + // ADR-021 US4: state สำหรับ FilePreviewModal + const [previewFile, setPreviewFile] = useState(null); + // ADR-021 T029: publicIds ของไฟล์ที่อัปโหลดใน WorkflowLifecycle Upload Zone + const [pendingAttachmentIds, setPendingAttachmentIds] = useState([]); + // ADR-021 T041: ติดตาม publicIds ที่ Storage แจ้ง 404 + const [unavailableIds, setUnavailableIds] = useState([]); + const handleUnavailable = (publicId: string) => + setUnavailableIds((prev) => [...new Set([...prev, publicId])]); if (!uuid) notFound(); - const { data: rfa, isLoading, isError } = useRFA(String(uuid)); - if (isLoading) { return (
@@ -20,10 +45,62 @@ export default function RFADetailPage() { ); } - if (isError || !rfa) { - // Check if error is 404 + if (isError || !rfaData) { return
RFA not found or failed to load.
; } - return ; + // ดึง Current Revision สำหรับแสดงใน Banner + const currentRevision = rfaData.revisions?.find((r) => r.isCurrent) ?? rfaData.revisions?.[0]; + const docNo = rfaData.correspondence?.correspondenceNumber ?? rfaData.correspondenceNumber ?? ''; + const subject = currentRevision?.subject ?? ''; + const status = currentRevision?.statusCode?.statusCode ?? ''; + + return ( +
+ {/* ADR-021: Integrated Banner — เลขเอกสาร + สถานะ + ปุ่ม Action */} + + + {/* Tabs — Details / Workflow (WorkflowLifecycle ถูกเพิ่มใน T019) */} + + + รายละเอียด + Workflow + + + + + + + + + + + + {/* ADR-021 US4: File Preview Modal */} + + setPreviewFile(null)} + onUnavailable={handleUnavailable} + /> + +
+ ); } diff --git a/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx b/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx index 3bea67d..e71e353 100644 --- a/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx +++ b/frontend/app/(dashboard)/transmittals/[uuid]/page.tsx @@ -1,14 +1,17 @@ 'use client'; +import { useState } from 'react'; import { useParams } from 'next/navigation'; -import { useQuery } from '@tanstack/react-query'; -import { transmittalService } from '@/lib/services/transmittal.service'; -import { Transmittal } from '@/types/transmittal'; +import { useTransmittal } from '@/hooks/use-transmittal'; +import { useWorkflowHistory } from '@/hooks/use-workflow-history'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { ArrowLeft, RefreshCw, Printer } from 'lucide-react'; +import { IntegratedBanner } from '@/components/workflow/integrated-banner'; +import { WorkflowLifecycle } from '@/components/workflow/workflow-lifecycle'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import Link from 'next/link'; import { format } from 'date-fns'; import { toast } from 'sonner'; @@ -16,16 +19,15 @@ import { toast } from 'sonner'; export default function TransmittalDetailPage() { const params = useParams(); const uuid = params.uuid as string; + const [pendingAttachmentIds, setPendingAttachmentIds] = useState([]); + + const { transmittal, isLoading, error } = useTransmittal(uuid); const { - data: transmittal, - isLoading, - error, - } = useQuery({ - queryKey: ['transmittal', uuid], - queryFn: () => transmittalService.getByUuid(uuid), - enabled: !!uuid, - }); + data: workflowHistory, + isLoading: historyLoading, + error: historyError, + } = useWorkflowHistory(transmittal?.workflowInstanceId); const handlePrint = () => { toast.info('PDF Export is coming soon...'); @@ -56,31 +58,46 @@ export default function TransmittalDetailPage() { ); } + const transmittalDocNo = transmittal.correspondence?.correspondenceNumber ?? transmittal.transmittalNo ?? ''; + const transmittalSubject = transmittal.subject ?? ''; + const transmittalStatus = transmittal.purpose ?? ''; + return ( -
- {/* Header */} +
+ {/* ADR-021: Integrated Banner — wired with live workflow data (v1.8.7) */} + + + {/* Navigation Header */}
-
- - - -
-

- {transmittal.correspondence?.correspondenceNumber || transmittal.transmittalNo} -

-

- {transmittal.correspondence?.revisions?.find((r) => r.isCurrent)?.title || transmittal.subject} -

-
-
- + +
+ {/* Tabs — Details / Workflow */} + + + รายละเอียด + Workflow + + + {/* Info Card */} @@ -154,6 +171,18 @@ export default function TransmittalDetailPage() { + + + {/* ADR-021: WorkflowLifecycle — wired with history data (v1.8.7) */} + + +
); } diff --git a/frontend/app/(dashboard)/transmittals/page.tsx b/frontend/app/(dashboard)/transmittals/page.tsx index fa957af..572d65c 100644 --- a/frontend/app/(dashboard)/transmittals/page.tsx +++ b/frontend/app/(dashboard)/transmittals/page.tsx @@ -10,10 +10,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Plus, RefreshCw } from 'lucide-react'; import Link from 'next/link'; import { TransmittalListResponse } from '@/types/transmittal'; +import { TransmittalPurpose } from '@/types/dto/transmittal/transmittal.dto'; + +const PURPOSE_OPTIONS: { value: TransmittalPurpose | ''; label: string }[] = [ + { value: '', label: 'All Purposes' }, + { value: TransmittalPurpose.FOR_APPROVAL, label: 'For Approval' }, + { value: TransmittalPurpose.FOR_INFORMATION, label: 'For Information' }, + { value: TransmittalPurpose.FOR_REVIEW, label: 'For Review' }, + { value: TransmittalPurpose.OTHER, label: 'Other' }, +]; export default function TransmittalPage() { // ADR-019: Dynamic project selection via UUID const [selectedProjectUuid, setSelectedProjectUuid] = useState(''); + const [selectedPurpose, setSelectedPurpose] = useState(''); const { data: projectsData } = useQuery({ queryKey: ['projects-for-transmittals'], @@ -22,8 +32,12 @@ export default function TransmittalPage() { const projects = projectsData?.data || projectsData || []; const { data, isLoading, error, refetch } = useQuery({ - queryKey: ['transmittals', selectedProjectUuid], - queryFn: () => transmittalService.getAll({ projectId: selectedProjectUuid }), + queryKey: ['transmittals', selectedProjectUuid, selectedPurpose], + queryFn: () => + transmittalService.getAll({ + projectId: selectedProjectUuid, + ...(selectedPurpose ? { purpose: selectedPurpose } : {}), + }), enabled: !!selectedProjectUuid, }); @@ -47,11 +61,11 @@ export default function TransmittalPage() {
- {/* ADR-019: Project filter */} -
+ {/* Filters: Project + Purpose (v1.8.7 B3) */} +
Project: + + Purpose: +
{error && ( diff --git a/frontend/components/common/file-preview-modal.tsx b/frontend/components/common/file-preview-modal.tsx new file mode 100644 index 0000000..200e283 --- /dev/null +++ b/frontend/components/common/file-preview-modal.tsx @@ -0,0 +1,165 @@ +'use client'; + +// ADR-021: FilePreviewModal — แสดงไฟล์แนบ Workflow Step โดยไม่บังคับดาวน์โหลด (US4) +// รองรับ: PDF (iframe), Image (img), อื่นๆ (ลิงก์ดาวน์โหลด) +// Auth: ดึงไฟล์ผ่าน apiClient เพื่อแนบ JWT header อัตโนมัติ → แปลงเป็น BlobURL + +import { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Loader2, Download, FileIcon } from 'lucide-react'; +import type { AxiosError } from 'axios'; +import apiClient from '@/lib/api/client'; +import { useTranslations } from '@/hooks/use-translations'; +import type { WorkflowAttachmentSummary } from '@/types/workflow'; + +interface FilePreviewModalProps { + attachment: WorkflowAttachmentSummary | null; + onClose: () => void; + // ADR-021 T041: เรียกเมื่อ API คืน 404 (ไฟล์ถูกลบออกจาก Storage) + onUnavailable?: (publicId: string) => void; +} + +// แปลง bytes เป็น KB/MB สำหรับแสดงขนาดไฟล์ +function formatBytes(bytes?: number): string { + if (!bytes) return ''; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// ตรวจสอบว่า mimeType เป็น PDF หรือ Image +function getPreviewType(mimeType?: string): 'pdf' | 'image' | 'none' { + if (!mimeType) return 'none'; + if (mimeType === 'application/pdf') return 'pdf'; + if (mimeType.startsWith('image/')) return 'image'; + return 'none'; +} + +export function FilePreviewModal({ attachment, onClose, onUnavailable }: FilePreviewModalProps) { + const t = useTranslations(); + const [blobUrl, setBlobUrl] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // ดึงไฟล์จาก API เมื่อ attachment เปลี่ยน — แปลงเป็น BlobURL เพื่อรองรับ JWT auth + useEffect(() => { + if (!attachment) { + setBlobUrl(null); + return; + } + + let currentUrl: string | null = null; + setIsLoading(true); + setError(null); + + apiClient + .get(`/files/preview/${attachment.publicId}`, { responseType: 'blob' }) + .then((res) => { + const url = URL.createObjectURL(res.data as Blob); + currentUrl = url; + setBlobUrl(url); + }) + .catch((err: AxiosError) => { + // ADR-021 T041: ตรวจสอบ 404 เพื่อแยกแยะกรณี ไฟล์ถูกลบ vs เกิดผิดพลาดอื่น + if (err.response?.status === 404) { + setError(t('filePreview.fileUnavailable')); + if (attachment?.publicId) onUnavailable?.(attachment.publicId); + } else { + setError(t('filePreview.loadError')); + } + }) + .finally(() => { + setIsLoading(false); + }); + + // Cleanup: เพิกถอน BlobURL เพื่อป้องกัน memory leak + return () => { + if (currentUrl) URL.revokeObjectURL(currentUrl); + }; + }, [attachment, onUnavailable, t]); + + const previewType = getPreviewType(attachment?.mimeType); + + return ( + !open && onClose()}> + + {/* Header — ชื่อไฟล์ + ขนาด */} + + + + {attachment?.originalFilename ?? t('filePreview.fallbackTitle')} + {attachment?.fileSize && ( + + {formatBytes(attachment.fileSize)} + + )} + + + + {/* Body — Preview Area */} +
+ {isLoading && ( +
+ +
+ )} + + {error && !isLoading && ( +
+ {error ?? t('filePreview.loadError')} +
+ )} + + {!isLoading && !error && blobUrl && previewType === 'pdf' && ( +