260318:1135 Fix UUID #3
Build and Deploy / deploy (push) Successful in 8m43s

This commit is contained in:
admin
2026-03-18 11:35:51 +07:00
parent 6172b058df
commit 5d89079c2a
43 changed files with 1073 additions and 132 deletions
+85
View File
@@ -0,0 +1,85 @@
---
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")
@@ -0,0 +1,18 @@
---
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
+19
View File
@@ -0,0 +1,19 @@
---
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
+18
View File
@@ -0,0 +1,18 @@
---
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
+18
View File
@@ -0,0 +1,18 @@
---
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
+19
View File
@@ -0,0 +1,19 @@
---
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
+22
View File
@@ -0,0 +1,22 @@
---
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
@@ -0,0 +1,20 @@
---
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
+21
View File
@@ -0,0 +1,21 @@
---
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
+21
View File
@@ -0,0 +1,21 @@
---
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
@@ -0,0 +1,19 @@
---
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
@@ -0,0 +1,19 @@
---
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
@@ -0,0 +1,51 @@
---
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-name>/
├── <module-name>.module.ts
├── <module-name>.controller.ts
├── <module-name>.service.ts
├── dto/
│ ├── create-<module-name>.dto.ts
│ └── update-<module-name>.dto.ts
├── entities/
│ └── <module-name>.entity.ts
└── <module-name>.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`
@@ -0,0 +1,64 @@
---
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)/<route>/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: '<Page Title> | LCBP3-DMS',
};
export default async function <PageName>Page() {
return (
<div>
{/* Page content */}
</div>
);
}
```
4. **Create API hook** (if client-side data needed) — add to `hooks/use-<feature>.ts`:
```typescript
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export function use<Feature>() {
return useQuery({
queryKey: ['<feature>'],
queryFn: () => apiClient.get('<endpoint>'),
});
}
```
5. **Build UI components** — use Shadcn/UI primitives. Place reusable components in `components/<feature>/`.
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`
+71
View File
@@ -0,0 +1,71 @@
---
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(<scope>): <description>"
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://<QNAP_IP>:3000/health
# Expected: { "status": "ok" }
```
5. **Verify frontend**
```bash
curl -I http://<QNAP_IP>: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 <service>=<previous-image-tag>
docker compose up -d <service>
```
## 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 |
+108
View File
@@ -0,0 +1,108 @@
---
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/<module>/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: <current date>
-- Feature: <feature name>
-- Tables affected: <list>
--
-- ⚠️ 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
+27
View File
@@ -0,0 +1,27 @@
---
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`.
@@ -0,0 +1,18 @@
---
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
+19
View File
@@ -0,0 +1,19 @@
---
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
@@ -0,0 +1,19 @@
---
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
@@ -0,0 +1,20 @@
---
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`).
@@ -0,0 +1,20 @@
---
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."
@@ -0,0 +1,18 @@
---
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
@@ -14,6 +14,8 @@ import { CreateCirculationDto } from './dto/create-circulation.dto';
import { UpdateCirculationRoutingDto } from './dto/update-circulation-routing.dto';
import { SearchCirculationDto } from './dto/search-circulation.dto';
import { DocumentNumberingService } from '../document-numbering/services/document-numbering.service';
import { Project } from '../project/entities/project.entity';
import { Correspondence } from '../correspondence/entities/correspondence.entity';
@Injectable()
export class CirculationService {
@@ -26,6 +28,58 @@ export class CirculationService {
private dataSource: DataSource
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Resolve correspondenceId (INT or UUID string) to internal INT ID
*/
private async resolveCorrespondenceId(
corrId: number | string
): Promise<number> {
if (typeof corrId === 'number') return corrId;
const num = Number(corrId);
if (!isNaN(num)) return num;
const corr = await this.dataSource.manager.findOne(Correspondence, {
where: { uuid: corrId },
select: ['id'],
});
if (!corr)
throw new NotFoundException(
`Correspondence with UUID ${corrId} not found`
);
return corr.id;
}
/**
* ADR-019: Resolve userId (INT or UUID string) to internal user_id
*/
private async resolveUserId(userId: number | string): Promise<number> {
if (typeof userId === 'number') return userId;
const num = Number(userId);
if (!isNaN(num)) return num;
const user = await this.dataSource.manager.findOne(User, {
where: { uuid: userId },
select: ['user_id'],
});
if (!user)
throw new NotFoundException(`User with UUID ${userId} not found`);
return user.user_id;
}
async create(createDto: CreateCirculationDto, user: User) {
if (!user.primaryOrganizationId) {
throw new BadRequestException('User must belong to an organization');
@@ -36,9 +90,20 @@ export class CirculationService {
await queryRunner.startTransaction();
try {
// ADR-019: Resolve UUID references to internal INT IDs
const resolvedProjectId = createDto.projectId
? await this.resolveProjectId(createDto.projectId)
: 0;
const resolvedCorrId = await this.resolveCorrespondenceId(
createDto.correspondenceId
);
const resolvedAssigneeIds = await Promise.all(
createDto.assigneeIds.map((id) => this.resolveUserId(id))
);
// Generate No. using DocumentNumberingService (Type 900 - Circulation)
const result = await this.numberingService.generateNextNumber({
projectId: createDto.projectId || 0, // Use projectId from DTO or 0
projectId: resolvedProjectId,
originatorOrganizationId: user.primaryOrganizationId,
typeId: 900, // Fixed Type ID for Circulation
year: new Date().getFullYear(),
@@ -50,7 +115,7 @@ export class CirculationService {
const circulation = queryRunner.manager.create(Circulation, {
organizationId: user.primaryOrganizationId,
correspondenceId: createDto.correspondenceId,
correspondenceId: resolvedCorrId,
circulationNo: result.number,
subject: createDto.subject,
statusCode: 'OPEN',
@@ -58,13 +123,13 @@ export class CirculationService {
});
const savedCirculation = await queryRunner.manager.save(circulation);
if (createDto.assigneeIds && createDto.assigneeIds.length > 0) {
const routings = createDto.assigneeIds.map((userId, index) =>
if (resolvedAssigneeIds.length > 0) {
const routings = resolvedAssigneeIds.map((assigneeId, index) =>
queryRunner.manager.create(CirculationRouting, {
circulationId: savedCirculation.id,
stepNumber: index + 1,
organizationId: user.primaryOrganizationId,
assignedTo: userId,
assignedTo: assigneeId,
status: 'PENDING',
})
);
@@ -1,29 +1,25 @@
import {
IsInt,
IsString,
IsNotEmpty,
IsArray,
IsOptional,
ArrayMinSize, // ✅ เพิ่ม
ArrayMinSize,
} from 'class-validator';
export class CreateCirculationDto {
@IsInt()
@IsNotEmpty()
correspondenceId!: number; // เอกสารต้นเรื่องที่จะเวียน
correspondenceId!: number | string; // เอกสารต้นเรื่องที่จะเวียน (INT or UUID)
@IsInt()
@IsOptional()
projectId?: number; // Project ID for Numbering
projectId?: number | string; // Project ID or UUID for Numbering
@IsString()
@IsNotEmpty()
subject!: string; // หัวข้อเรื่อง (Subject)
@IsArray()
@IsInt({ each: true })
@ArrayMinSize(1) // ✅ ต้องมีผู้รับอย่างน้อย 1 คน
assigneeIds!: number[]; // รายชื่อ User ID ที่ต้องการส่งให้ (ผู้รับผิดชอบ)
assigneeIds!: (number | string)[]; // รายชื่อ User ID or UUID ที่ต้องการส่งให้ (ADR-019)
@IsString()
@IsOptional()
@@ -35,6 +35,7 @@ import { WorkflowEngineService } from '../workflow-engine/workflow-engine.servic
import { UserService } from '../user/user.service';
import { SearchService } from '../search/search.service';
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { Project } from '../project/entities/project.entity';
/**
* CorrespondenceService - Document management (CRUD)
@@ -69,7 +70,52 @@ export class CorrespondenceService {
private fileStorageService: FileStorageService
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.dataSource.manager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
/**
* ADR-019: Resolve organizationId (INT or UUID string) to internal INT ID
*/
private async resolveOrganizationId(orgId: number | string): Promise<number> {
if (typeof orgId === 'number') return orgId;
const num = Number(orgId);
if (!isNaN(num)) return num;
const org = await this.orgRepo.findOne({
where: { uuid: orgId },
select: ['id'],
});
if (!org)
throw new NotFoundException(`Organization with UUID ${orgId} not found`);
return org.id;
}
async create(createDto: CreateCorrespondenceDto, user: User) {
// ADR-019: Resolve UUID references to internal INT IDs
const resolvedProjectId = await this.resolveProjectId(createDto.projectId);
const resolvedOriginatorId = createDto.originatorId
? await this.resolveOrganizationId(createDto.originatorId)
: undefined;
const resolvedRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(async (r) => ({
organizationId: await this.resolveOrganizationId(r.organizationId),
type: r.type,
}))
)
: undefined;
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
@@ -94,7 +140,7 @@ export class CorrespondenceService {
}
// Impersonation Logic
if (createDto.originatorId && createDto.originatorId !== userOrgId) {
if (resolvedOriginatorId && resolvedOriginatorId !== userOrgId) {
const permissions = await this.userService.getUserPermissions(
user.user_id
);
@@ -103,7 +149,7 @@ export class CorrespondenceService {
'You do not have permission to create documents on behalf of other organizations.'
);
}
userOrgId = createDto.originatorId;
userOrgId = resolvedOriginatorId;
}
if (!userOrgId) {
@@ -134,7 +180,7 @@ export class CorrespondenceService {
const orgCode = originatorOrg?.organizationCode ?? 'UNK';
// [v1.5.1] Extract recipient organization from recipients array (Primary TO)
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
const toRecipient = resolvedRecipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
@@ -146,7 +192,7 @@ export class CorrespondenceService {
}
const docNumber = await this.numberingService.generateNextNumber({
projectId: createDto.projectId,
projectId: resolvedProjectId,
originatorOrganizationId: userOrgId,
typeId: createDto.typeId,
disciplineId: createDto.disciplineId,
@@ -165,7 +211,7 @@ export class CorrespondenceService {
correspondenceNumber: docNumber.number,
correspondenceTypeId: createDto.typeId,
disciplineId: createDto.disciplineId,
projectId: createDto.projectId,
projectId: resolvedProjectId,
originatorId: userOrgId,
isInternal: createDto.isInternal || false,
createdBy: user.user_id,
@@ -195,9 +241,9 @@ export class CorrespondenceService {
});
await queryRunner.manager.save(revision);
// Save Recipients
if (createDto.recipients && createDto.recipients.length > 0) {
const recipients = createDto.recipients.map((r) =>
// Save Recipients (using resolved INT IDs)
if (resolvedRecipients && resolvedRecipients.length > 0) {
const recipients = resolvedRecipients.map((r) =>
queryRunner.manager.create(CorrespondenceRecipient, {
correspondenceId: savedCorr.id,
recipientOrganizationId: r.organizationId,
@@ -459,14 +505,30 @@ export class CorrespondenceService {
}
}
// ADR-019: Resolve UUID references in update DTO
const updResolvedProjectId = updateDto.projectId
? await this.resolveProjectId(updateDto.projectId)
: undefined;
const updResolvedOriginatorId = updateDto.originatorId
? await this.resolveOrganizationId(updateDto.originatorId)
: undefined;
const updResolvedRecipients = updateDto.recipients
? await Promise.all(
updateDto.recipients.map(async (r) => ({
organizationId: await this.resolveOrganizationId(r.organizationId),
type: r.type,
}))
)
: undefined;
// 3. Update Correspondence Entity if needed
const correspondenceUpdate: DeepPartial<Correspondence> = {};
if (updateDto.disciplineId)
correspondenceUpdate.disciplineId = updateDto.disciplineId;
if (updateDto.projectId)
correspondenceUpdate.projectId = updateDto.projectId;
if (updateDto.originatorId)
correspondenceUpdate.originatorId = updateDto.originatorId;
if (updResolvedProjectId)
correspondenceUpdate.projectId = updResolvedProjectId;
if (updResolvedOriginatorId)
correspondenceUpdate.originatorId = updResolvedOriginatorId;
if (Object.keys(correspondenceUpdate).length > 0) {
await this.correspondenceRepo.update(id, correspondenceUpdate);
@@ -506,13 +568,13 @@ export class CorrespondenceService {
}
// 5. Update Recipients if provided
if (updateDto.recipients) {
if (updResolvedRecipients) {
const recipientRepo = this.dataSource.getRepository(
CorrespondenceRecipient
);
await recipientRepo.delete({ correspondenceId: id });
const newRecipients = updateDto.recipients.map((r) =>
const newRecipients = updResolvedRecipients.map((r) =>
recipientRepo.create({
correspondenceId: id,
recipientOrganizationId: r.organizationId,
@@ -539,11 +601,11 @@ export class CorrespondenceService {
// Check for ACTUAL value changes
const isProjectChanged =
updateDto.projectId !== undefined &&
updateDto.projectId !== currentCorr.projectId;
updResolvedProjectId !== undefined &&
updResolvedProjectId !== currentCorr.projectId;
const isOriginatorChanged =
updateDto.originatorId !== undefined &&
updateDto.originatorId !== currentCorr.originatorId;
updResolvedOriginatorId !== undefined &&
updResolvedOriginatorId !== currentCorr.originatorId;
const isDisciplineChanged =
updateDto.disciplineId !== undefined &&
updateDto.disciplineId !== currentCorr.disciplineId;
@@ -554,15 +616,9 @@ export class CorrespondenceService {
let isRecipientChanged = false;
let newRecipientId: number | undefined;
if (updateDto.recipients) {
// Safe check for 'type' or 'recipientType' (mismatch safeguard)
interface RecipientInput {
type?: string;
recipientType?: string;
organizationId?: number;
}
const newToRecipient = updateDto.recipients.find(
(r: RecipientInput) => r.type === 'TO' || r.recipientType === 'TO'
if (updResolvedRecipients) {
const newToRecipient = updResolvedRecipients.find(
(r) => r.type === 'TO'
);
newRecipientId = newToRecipient?.organizationId;
@@ -594,7 +650,7 @@ export class CorrespondenceService {
// [Fix #6] Fetch real ORG Code from originator organization
const originatorOrgForUpdate = await this.orgRepo.findOne({
where: {
id: updateDto.originatorId ?? currentCorr.originatorId ?? 0,
id: updResolvedOriginatorId ?? currentCorr.originatorId ?? 0,
},
});
const orgCode = originatorOrgForUpdate?.organizationCode ?? 'UNK';
@@ -610,9 +666,9 @@ export class CorrespondenceService {
};
const newCtx = {
projectId: updateDto.projectId ?? currentCorr.projectId,
projectId: updResolvedProjectId ?? currentCorr.projectId,
originatorOrganizationId:
updateDto.originatorId ?? currentCorr.originatorId ?? 0,
updResolvedOriginatorId ?? currentCorr.originatorId ?? 0,
typeId: updateDto.typeId ?? currentCorr.correspondenceTypeId,
disciplineId: updateDto.disciplineId ?? currentCorr.disciplineId,
recipientOrganizationId: targetRecipientId,
@@ -650,6 +706,20 @@ export class CorrespondenceService {
}
async previewDocumentNumber(createDto: CreateCorrespondenceDto, user: User) {
// ADR-019: Resolve UUID references
const previewProjectId = await this.resolveProjectId(createDto.projectId);
const previewOriginatorId = createDto.originatorId
? await this.resolveOrganizationId(createDto.originatorId)
: undefined;
const previewRecipients = createDto.recipients
? await Promise.all(
createDto.recipients.map(async (r) => ({
organizationId: await this.resolveOrganizationId(r.organizationId),
type: r.type,
}))
)
: undefined;
const type = await this.typeRepo.findOne({
where: { id: createDto.typeId },
});
@@ -661,13 +731,13 @@ export class CorrespondenceService {
if (fullUser) userOrgId = fullUser.primaryOrganizationId;
}
if (createDto.originatorId && createDto.originatorId !== userOrgId) {
if (previewOriginatorId && previewOriginatorId !== userOrgId) {
// Allow impersonation for preview
userOrgId = createDto.originatorId;
userOrgId = previewOriginatorId;
}
// Extract recipient from recipients array
const toRecipient = createDto.recipients?.find((r) => r.type === 'TO');
const toRecipient = previewRecipients?.find((r) => r.type === 'TO');
const recipientOrganizationId = toRecipient?.organizationId;
let recipientCode = '';
@@ -679,7 +749,7 @@ export class CorrespondenceService {
}
return this.numberingService.previewNumber({
projectId: createDto.projectId,
projectId: previewProjectId,
originatorOrganizationId: userOrgId!,
typeId: createDto.typeId,
disciplineId: createDto.disciplineId,
@@ -11,10 +11,9 @@ import {
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateCorrespondenceDto {
@ApiProperty({ description: 'Project ID', example: 1 })
@IsInt()
@ApiProperty({ description: 'Project ID or UUID', example: 1 })
@IsNotEmpty()
projectId!: number;
projectId!: number | string;
@ApiProperty({ description: 'Document Type ID', example: 1 })
@IsInt()
@@ -110,12 +109,11 @@ export class CreateCorrespondenceDto {
// ✅ เพิ่ม Field สำหรับ Impersonation (เลือกองค์กรผู้ส่ง)
@ApiPropertyOptional({
description: 'Originator Organization ID (for impersonation)',
description: 'Originator Organization ID or UUID (for impersonation)',
example: 1,
})
@IsInt()
@IsOptional()
originatorId?: number;
originatorId?: number | string;
@ApiPropertyOptional({
description: 'Recipients',
@@ -123,5 +121,5 @@ export class CreateCorrespondenceDto {
})
@IsArray()
@IsOptional()
recipients?: { organizationId: number; type: 'TO' | 'CC' }[];
recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[];
}
@@ -1,6 +1,11 @@
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
Injectable,
Logger,
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository, InjectEntityManager } from '@nestjs/typeorm';
import { Repository, EntityManager } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { DocumentNumberFormat } from '../entities/document-number-format.entity';
@@ -20,6 +25,7 @@ import { CounterKeyDto } from '../dto/counter-key.dto';
import { GenerateNumberContext } from '../interfaces/document-numbering.interface';
import { ReserveNumberDto } from '../dto/reserve-number.dto';
import { ConfirmReservationDto } from '../dto/confirm-reservation.dto';
import { Project } from '../../project/entities/project.entity';
@Injectable()
export class DocumentNumberingService {
@@ -39,9 +45,27 @@ export class DocumentNumberingService {
private lockService: DocumentNumberingLockService,
private configService: ConfigService,
private manualOverrideService: ManualOverrideService,
private metricsService: MetricsService
private metricsService: MetricsService,
@InjectEntityManager()
private entityManager: EntityManager
) {}
/**
* ADR-019: Resolve projectId (INT or UUID string) to internal INT ID
*/
private async resolveProjectId(projectId: number | string): Promise<number> {
if (typeof projectId === 'number') return projectId;
const num = Number(projectId);
if (!isNaN(num)) return num;
const project = await this.entityManager.findOne(Project, {
where: { uuid: projectId },
select: ['id'],
});
if (!project)
throw new NotFoundException(`Project with UUID ${projectId} not found`);
return project.id;
}
async generateNextNumber(
ctx: GenerateNumberContext
): Promise<{ number: string; auditId: number }> {
@@ -218,11 +242,15 @@ export class DocumentNumberingService {
return this.formatRepo.find();
}
async getTemplatesByProject(projectId: number) {
return this.formatRepo.find({ where: { projectId } });
async getTemplatesByProject(projectId: number | string) {
const internalId = await this.resolveProjectId(projectId);
return this.formatRepo.find({ where: { projectId: internalId } });
}
async saveTemplate(dto: any) {
if (dto.projectId) {
dto.projectId = await this.resolveProjectId(dto.projectId);
}
return this.formatRepo.save(dto);
}
@@ -6,7 +6,10 @@ import {
UpdateDateColumn,
DeleteDateColumn,
Unique,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Project } from '../../project/entities/project.entity';
@Entity('tags')
@Unique('ux_tag_project', ['project_id', 'tag_name'])
@@ -26,6 +29,11 @@ export class Tag {
@Column({ type: 'text', nullable: true })
description!: string | null; // เพิ่ม !
// Relations
@ManyToOne(() => Project)
@JoinColumn({ name: 'project_id' })
project?: Project;
@CreateDateColumn()
created_at!: Date; // เพิ่ม !
+4 -1
View File
@@ -132,6 +132,7 @@ export class MasterService {
}
return this.rfaTypeRepo.find({
where,
relations: ['contract'],
order: { typeCode: 'ASC' },
});
}
@@ -296,7 +297,9 @@ export class MasterService {
}
async findAllTags(query?: SearchTagDto) {
const qb = this.tagRepo.createQueryBuilder('tag');
const qb = this.tagRepo
.createQueryBuilder('tag')
.leftJoinAndSelect('tag.project', 'project');
if (query?.project_id) {
// In Tags, we use project_id (INT) directly or resolve if UUID passed via query
@@ -1,4 +1,12 @@
import { Entity, PrimaryGeneratedColumn, Column, AfterLoad } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
AfterLoad,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Contract } from '../../contract/entities/contract.entity';
@Entity('rfa_types')
export class RfaType {
@@ -23,6 +31,11 @@ export class RfaType {
@Column({ name: 'is_active', default: true })
isActive!: boolean;
// Relations
@ManyToOne(() => Contract)
@JoinColumn({ name: 'contract_id' })
contract?: Contract;
// Virtual property for backward compatibility
typeName!: string;
@@ -45,7 +45,7 @@ export default function DisciplinesPage() {
const contractOptions = contracts.map((c: any) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.id || c.uuid),
value: String(c.id),
}));
return (
@@ -55,9 +55,16 @@ export default function DisciplinesPage() {
title="Disciplines Management"
description="Manage system disciplines (e.g., ARCH, STR, MEC)"
queryKey={['disciplines', selectedContractId ?? 'all']}
fetchFn={() => masterDataService.getDisciplines(selectedContractId ? selectedContractId : undefined)}
fetchFn={async () => {
const items = await masterDataService.getDisciplines(selectedContractId ? selectedContractId : undefined);
// ADR-019: Map contractId INT → contract UUID for edit mode select matching
return (items as any[]).map((item: any) => ({
...item,
contractId: item.contract?.id || item.contract?.uuid || String(item.contractId),
}));
}}
createFn={(data: Record<string, unknown>) => masterDataService.createDiscipline(data as any)}
updateFn={(id, data) => Promise.reject('Not implemented yet')}
updateFn={(id, data) => Promise.reject('Not implemented yet')}
deleteFn={(id) => masterDataService.deleteDiscipline(id)}
columns={columns}
filters={
@@ -72,7 +79,7 @@ export default function DisciplinesPage() {
<SelectContent>
<SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c: any) => (
<SelectItem key={c.uuid || c.id} value={String(c.id || c.uuid)}>
<SelectItem key={c.id} value={String(c.id)}>
{c.contractName} ({c.contractCode})
</SelectItem>
))}
@@ -49,7 +49,7 @@ export default function RfaTypesPage() {
const contractOptions = contracts.map((c: any) => ({
label: `${c.contractName} (${c.contractCode})`,
value: String(c.id || c.uuid),
value: String(c.id),
}));
return (
@@ -58,7 +58,14 @@ export default function RfaTypesPage() {
entityName="RFA Type"
title="RFA Types Management"
queryKey={['rfa-types', selectedContractId ?? 'all']}
fetchFn={() => masterDataService.getRfaTypes(selectedContractId ? selectedContractId : undefined)}
fetchFn={async () => {
const items = await masterDataService.getRfaTypes(selectedContractId ? selectedContractId : undefined);
// ADR-019: Map contractId INT → contract UUID for edit mode select matching
return (items as any[]).map((item: any) => ({
...item,
contractId: item.contract?.id || item.contract?.uuid || String(item.contractId),
}));
}}
createFn={(data: Record<string, unknown>) => masterDataService.createRfaType(data as any)}
updateFn={(id, data) => masterDataService.updateRfaType(id, data)}
deleteFn={(id) => masterDataService.deleteRfaType(id)}
@@ -75,7 +82,7 @@ export default function RfaTypesPage() {
<SelectContent>
<SelectItem value="all">All Contracts</SelectItem>
{contracts.map((c: any) => (
<SelectItem key={c.uuid || c.id} value={String(c.id || c.uuid)}>
<SelectItem key={c.id} value={String(c.id)}>
{c.contractName} ({c.contractCode})
</SelectItem>
))}
@@ -16,8 +16,8 @@ export default function TagsPage() {
const projectOptions = [
{ label: "Global (All Projects)", value: "__none__" },
...(projectsData || []).map((p: Record<string, unknown>) => ({
label: p.projectName || p.projectCode || p.project_name || p.project_code || `Project ${p.id}`,
value: String(p.id),
label: (p.projectName || p.projectCode || p.project_name || p.project_code || `Project ${p.id}`) as string,
value: String(p.id), // p.id = UUID string via serialization
})),
];
@@ -26,10 +26,10 @@ export default function TagsPage() {
accessorKey: "project_id",
header: "Project",
cell: ({ row }) => {
const pId = row.original.project_id;
if (!pId) return <span className="text-muted-foreground italic">Global</span>;
const p = (projectsData || []).find((proj: Record<string, unknown>) => proj.id === pId);
return p ? (p.projectName || p.projectCode || p.project_name || p.project_code || `Project ${pId}`) as React.ReactNode : pId as React.ReactNode;
const item = row.original as Record<string, unknown>;
const project = item.project as Record<string, unknown> | null;
if (!project) return <span className="text-muted-foreground italic">Global</span>;
return (project.projectName || project.projectCode || `Project ${project.id}`) as React.ReactNode;
},
},
{
@@ -70,7 +70,14 @@ export default function TagsPage() {
description="Manage system tags, multi-tenant capable."
entityName="Tag"
queryKey={["tags"]}
fetchFn={() => masterDataService.getTags()}
fetchFn={async () => {
const items = await masterDataService.getTags();
// ADR-019: Map project_id INT → project UUID for edit mode select matching
return (items as any[]).map((item: any) => ({
...item,
project_id: item.project?.id || item.project?.uuid || (item.project_id ? String(item.project_id) : null),
}));
}}
createFn={(data: Record<string, unknown>) => masterDataService.createTag(formatPayload(data) as unknown as CreateTagDto)}
updateFn={(id, data) => masterDataService.updateTag(id, formatPayload(data))}
deleteFn={(id) => masterDataService.deleteTag(id)}
@@ -45,9 +45,9 @@ import { cn } from "@/lib/utils";
// Form validation schema
const formSchema = z.object({
correspondenceId: z.number(),
correspondenceId: z.string().min(1, "Please select a document"),
subject: z.string().min(1, "Subject is required"),
assigneeIds: z.array(z.number()).min(1, "At least one assignee is required"),
assigneeIds: z.array(z.string()).min(1, "At least one assignee is required"),
remarks: z.string().optional(),
});
@@ -98,18 +98,18 @@ export default function CreateCirculationPage() {
const selectedDocId = form.watch("correspondenceId");
const selectedDoc = correspondences?.data?.find(
(c: { id: number }) => c.id === selectedDocId
(c: { uuid: string }) => c.uuid === selectedDocId
);
const toggleAssignee = (userId: number) => {
const toggleAssignee = (userUuid: string) => {
const current = form.getValues("assigneeIds");
if (current.includes(userId)) {
if (current.includes(userUuid)) {
form.setValue(
"assigneeIds",
current.filter((id) => id !== userId)
current.filter((id) => id !== userUuid)
);
} else {
form.setValue("assigneeIds", [...current, userId]);
form.setValue("assigneeIds", [...current, userUuid]);
}
};
@@ -168,19 +168,19 @@ export default function CreateCirculationPage() {
<CommandList>
<CommandEmpty>No document found.</CommandEmpty>
<CommandGroup>
{correspondences?.data?.map((doc: { id: number; correspondenceNumber: string }) => (
{correspondences?.data?.map((doc: { uuid: string; correspondenceNumber: string }) => (
<CommandItem
key={doc.id}
key={doc.uuid}
value={doc.correspondenceNumber}
onSelect={() => {
form.setValue("correspondenceId", doc.id);
form.setValue("correspondenceId", doc.uuid);
setDocOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
doc.id === field.value
doc.uuid === field.value
? "opacity-100"
: "opacity-0"
)}
@@ -230,13 +230,13 @@ export default function CreateCirculationPage() {
>
{selectedAssignees.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedAssignees.map((userId) => {
{selectedAssignees.map((userUuid) => {
const user = users.find(
(u) => u.userId === userId
(u) => u.uuid === userUuid
);
return user ? (
<Badge
key={userId}
key={userUuid}
variant="secondary"
className="mr-1"
>
@@ -245,7 +245,7 @@ export default function CreateCirculationPage() {
className="ml-1 h-3 w-3 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
toggleAssignee(userId);
toggleAssignee(userUuid);
}}
/>
</Badge>
@@ -269,14 +269,14 @@ export default function CreateCirculationPage() {
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.userId ?? user.uuid}
key={user.uuid}
value={user.username}
onSelect={() => user.userId && toggleAssignee(user.userId)}
onSelect={() => toggleAssignee(user.uuid)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
user.userId != null && selectedAssignees.includes(user.userId)
selectedAssignees.includes(user.uuid)
? "opacity-100"
: "opacity-0"
)}
+16 -14
View File
@@ -26,7 +26,7 @@ import { correspondenceService } from "@/lib/services/correspondence.service";
// Updated Zod Schema with all required fields
const correspondenceSchema = z.object({
projectId: z.number().min(1, "Please select a Project"),
projectId: z.string().min(1, "Please select a Project"),
documentTypeId: z.number().min(1, "Please select a Document Type"),
disciplineId: z.number().optional(),
subject: z.string().min(5, "Subject must be at least 5 characters"),
@@ -34,8 +34,8 @@ const correspondenceSchema = z.object({
body: z.string().optional(),
remarks: z.string().optional(),
dueDate: z.string().optional(), // ISO Date string
fromOrganizationId: z.number().min(1, "Please select From Organization"),
toOrganizationId: z.number().min(1, "Please select To Organization"),
fromOrganizationId: z.string().min(1, "Please select From Organization"),
toOrganizationId: z.string().min(1, "Please select To Organization"),
importance: z.enum(["NORMAL", "HIGH", "URGENT"]),
attachments: z.array(z.instanceof(File)).optional(),
});
@@ -56,7 +56,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
// Extract initial values if editing
const currentRev = initialData?.revisions?.find((r: any) => r.isCurrent) || initialData?.revisions?.[0];
const defaultValues: Partial<FormData> = {
projectId: initialData?.projectId || undefined,
projectId: initialData?.projectId ? String(initialData.projectId) : undefined,
documentTypeId: initialData?.correspondenceTypeId || undefined,
disciplineId: initialData?.disciplineId || undefined,
subject: currentRev?.subject || currentRev?.title || "",
@@ -64,9 +64,11 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
body: currentRev?.body || "",
remarks: currentRev?.remarks || "",
dueDate: currentRev?.dueDate ? new Date(currentRev.dueDate).toISOString().split('T')[0] : undefined,
fromOrganizationId: initialData?.originatorId || undefined,
fromOrganizationId: initialData?.originatorId ? String(initialData.originatorId) : undefined,
// Map initial recipient (TO) - Simplified for now
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId || undefined,
toOrganizationId: initialData?.recipients?.find((r: any) => r.recipientType === 'TO')?.recipientOrganizationId
? String(initialData.recipients.find((r: any) => r.recipientType === 'TO').recipientOrganizationId)
: undefined,
importance: currentRev?.details?.importance || "NORMAL",
};
@@ -209,8 +211,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>Project *</Label>
<Select
onValueChange={(v) => setValue("projectId", parseInt(v))}
value={projectId ? String(projectId) : undefined}
onValueChange={(v) => setValue("projectId", v)}
value={projectId || undefined}
disabled={isLoadingProjects}
>
<SelectTrigger>
@@ -323,8 +325,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>From Organization *</Label>
<Select
onValueChange={(v) => setValue("fromOrganizationId", parseInt(v))}
value={fromOrgId ? String(fromOrgId) : undefined}
onValueChange={(v) => setValue("fromOrganizationId", v)}
value={fromOrgId || undefined}
disabled={isLoadingOrgs}
>
<SelectTrigger>
@@ -332,7 +334,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
</SelectTrigger>
<SelectContent>
{(organizations || []).map((org: Organization) => (
<SelectItem key={org.id} value={String(org.id)}>
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationName} ({org.organizationCode})
</SelectItem>
))}
@@ -346,8 +348,8 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
<div className="space-y-2">
<Label>To Organization *</Label>
<Select
onValueChange={(v) => setValue("toOrganizationId", parseInt(v))}
value={toOrgId ? String(toOrgId) : undefined}
onValueChange={(v) => setValue("toOrganizationId", v)}
value={toOrgId || undefined}
disabled={isLoadingOrgs}
>
<SelectTrigger>
@@ -355,7 +357,7 @@ export function CorrespondenceForm({ initialData, uuid }: { initialData?: any, u
</SelectTrigger>
<SelectContent>
{(organizations || []).map((org: Organization) => (
<SelectItem key={org.id} value={String(org.id)}>
<SelectItem key={org.uuid} value={org.uuid}>
{org.organizationName} ({org.organizationCode})
</SelectItem>
))}
+4 -1
View File
@@ -4,6 +4,7 @@ import { ColumnDef } from '@tanstack/react-table';
import { Drawing } from '@/types/drawing';
import { Button } from '@/components/ui/button';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import Link from 'next/link';
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,7 +67,9 @@ export const columns: ColumnDef<Drawing>[] = [
Copy Drawing No.
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>View Details</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/drawings/${drawing.uuid}`}>View Details</Link>
</DropdownMenuItem>
{/* Add download/view functionality later */}
</DropdownMenuContent>
</DropdownMenu>
@@ -68,8 +68,8 @@ export function TemplateTester({ open, onOpenChange, template }: TemplateTesterP
try {
const result = await numberingApi.previewNumber({
projectId: projectId,
originatorOrganizationId: parseInt(testData.originatorId || "0"),
recipientOrganizationId: parseInt(testData.recipientId || "0"),
originatorOrganizationId: testData.originatorId || "0",
recipientOrganizationId: testData.recipientId || "0",
correspondenceTypeId: parseInt(testData.correspondenceTypeId || "0"),
disciplineId: parseInt(testData.disciplineId || "0"),
});
+3 -3
View File
@@ -33,7 +33,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
if (response && response.data) {
const mappedData = response.data.map((d: ContractDrawing) => ({
...d,
uuid: d.uuid,
uuid: d.uuid || (d as unknown as { id: string }).id,
drawingNumber: d.contractDrawingNo,
type: 'CONTRACT',
}));
@@ -46,7 +46,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
if (response && response.data) {
const mappedData = response.data.map((d: ShopDrawing) => ({
...d,
uuid: d.uuid,
uuid: d.uuid || (d as unknown as { id: string }).id,
type: 'SHOP',
title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber,
@@ -61,7 +61,7 @@ export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
if (response && response.data) {
const mappedData = response.data.map((d: AsBuiltDrawing) => ({
...d,
uuid: d.uuid,
uuid: d.uuid || (d as unknown as { id: string }).id,
type: 'AS_BUILT',
title: d.currentRevision?.title || 'Untitled',
revision: d.currentRevision?.revisionNumber,
+4 -4
View File
@@ -34,7 +34,7 @@ export interface NumberingTemplate {
*/
export interface SaveTemplateDto {
id?: number; // If present, update; otherwise create
projectId: number;
projectId: number | string;
correspondenceTypeId: number | null;
formatTemplate: string;
description?: string;
@@ -273,13 +273,13 @@ export const numberingApi = {
* Preview what a document number would look like (without generating)
*/
previewNumber: async (ctx: {
projectId: number;
originatorOrganizationId: number;
projectId: number | string;
originatorOrganizationId: number | string;
correspondenceTypeId: number;
disciplineId?: number;
subTypeId?: number;
rfaTypeId?: number;
recipientOrganizationId?: number;
recipientOrganizationId?: number | string;
}): Promise<{ previewNumber: string; nextSequence: number }> => {
const res = await apiClient.post<{ data: { previewNumber: string; nextSequence: number } }>(
'/document-numbering/preview',
+3 -3
View File
@@ -89,10 +89,10 @@ export interface CirculationListResponse {
* DTO for creating a circulation
*/
export interface CreateCirculationDto {
correspondenceId: number;
projectId?: number;
correspondenceId: number | string;
projectId?: number | string;
subject: string;
assigneeIds: number[];
assigneeIds: (number | string)[];
remarks?: string;
}
@@ -1,15 +1,15 @@
// File: src/types/dto/circulation/create-circulation.dto.ts
export interface CreateCirculationDto {
/** เอกสารต้นเรื่องที่จะเวียน (Correspondence ID) */
correspondenceId: number;
/** เอกสารต้นเรื่องที่จะเวียน (Correspondence ID or UUID) */
correspondenceId: number | string;
/** หัวข้อเรื่อง (Subject) */
subject: string;
subject: string;
/** รายชื่อ User ID ที่ต้องการส่งให้ (ผู้รับผิดชอบ) */
assigneeIds: number[];
/** รายชื่อ User ID/UUID ที่ต้องการส่งให้ (ผู้รับผิดชอบ) */
assigneeIds: (number | string)[];
/** หมายเหตุเพิ่มเติม (ถ้ามี) */
remarks?: string;
}
remarks?: string;
}
@@ -1,8 +1,8 @@
// File: src/types/dto/correspondence/create-correspondence.dto.ts
export interface CreateCorrespondenceDto {
/** ID ของโครงการ */
projectId: number;
/** ID or UUID ของโครงการ */
projectId: number | string;
/** ID ของประเภทเอกสาร (เช่น RFA, LETTER) */
typeId: number;
@@ -37,8 +37,8 @@ export interface CreateCorrespondenceDto {
/** * Field Impersonation ()
* Admin
*/
originatorId?: number;
originatorId?: number | string;
/** รายชื่อผู้รับ */
recipients?: { organizationId: number; type: 'TO' | 'CC' }[];
recipients?: { organizationId: number | string; type: 'TO' | 'CC' }[];
}