260322:1648 Correct Coresspondence / Doing RFA / Correct CI
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
**Objective:** Resolve Critical Build Failures
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This session addressed critical TypeScript build errors in the backend that were preventing successful compilation (`pnpm build`). These errors originated from stricter TypeScript settings interacting with legacy P0 code and recent refactors.
|
||||
|
||||
**Result:** `pnpm build` now passes successfully.
|
||||
@@ -12,6 +13,7 @@ This session addressed critical TypeScript build errors in the backend that were
|
||||
## Fixed Issues
|
||||
|
||||
### 1. Workflow DSL Parser (`parser.service.ts`)
|
||||
|
||||
- **Issue:** Property mismatches between DSL JSON and `WorkflowDefinition` entity (camelCase vs snake_case).
|
||||
- **Fix:** Mapped properties correctly:
|
||||
- `dsl.name` -> `entity.workflow_code`
|
||||
@@ -21,33 +23,40 @@ This session addressed critical TypeScript build errors in the backend that were
|
||||
- **Fix:** Cast error to `any` and added fallback logic.
|
||||
|
||||
### 2. Permissions Guard (`permissions.guard.ts`)
|
||||
|
||||
- **Issue:** Strict type checking failures in `Ability.can(action, subject)`.
|
||||
- **Fix:** Explicitly cast action and subject to `any` to satisfy the CASL Ability type signature.
|
||||
|
||||
### 3. Ability Factory (`ability.factory.ts`)
|
||||
|
||||
- **Issue:** `item.constructor` access on potentially unknown type.
|
||||
- **Fix:** Explicitly typed `item` as `any` in `detectSubjectType`.
|
||||
|
||||
### 4. RBAC Guard (`rbac.guard.ts`)
|
||||
|
||||
- **Issue:** Incorrect import (`PERMISSION_KEY` vs `PERMISSIONS_KEY`) and mismatch with updated Decorator (Array vs String).
|
||||
- **Fix:** Updated to use `PERMISSIONS_KEY` and handle array of permissions. Fixed import paths (removed `.js`).
|
||||
|
||||
### 5. Document Numbering Service
|
||||
|
||||
- **Issue:** Unknown error type in catch block.
|
||||
- **Fix:** Cast error to `any` for logging.
|
||||
|
||||
### 6. P0-1: RBAC Tests (`ability.factory.spec.ts`)
|
||||
|
||||
- **Issue:** Tests failed to load due to `Cannot find module ... .js`.
|
||||
- **Fix:** Removed `.js` extensions from imports in `organization.entity.ts`, `project.entity.ts`, `contract.entity.ts`, `routing-template.entity.ts`.
|
||||
- **Issue:** Global Admin test failed (`can('manage', 'all')` -> false).
|
||||
- **Fix:**
|
||||
1. Updated `detectSubjectType` to return string subjects directly (fixing CASL string matching).
|
||||
2. Moved `system.manage_all` check to top of `parsePermission` to prevent incorrect splitting.
|
||||
1. Updated `detectSubjectType` to return string subjects directly (fixing CASL string matching).
|
||||
2. Moved `system.manage_all` check to top of `parsePermission` to prevent incorrect splitting.
|
||||
- **Verification:** `pnpm test src/common/auth/casl/ability.factory.spec.ts` -> **PASS** (7/7 tests).
|
||||
|
||||
## Verification
|
||||
|
||||
- Ran `pnpm build`.
|
||||
- **Outcome:** Success (Exit code 0).
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Continue to P3 (Admin Panel) or P2-5 (Tests) knowing the foundation is stable.
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
# P1-Frontend: Setup & Authentication Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Finalize frontend setup and implement robust Authentication connecting to the NestJS Backend (P2-2 Refresh Token support).
|
||||
|
||||
## Status Analysis
|
||||
|
||||
- **P1-1 (Setup):** ✅ Project structure, Tailwind, Shadcn/UI are already present.
|
||||
- **P1-2 (Auth):** 🚧 `lib/auth.ts` exists but lacks `refreshToken` rotation logic. Types need verification.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Type Definitions (`types/next-auth.d.ts`)
|
||||
|
||||
- [ ] Add `refreshToken`, `accessTokenExpires` (optional), and `error` field to `Session` and `JWT` types.
|
||||
|
||||
### 2. Auth Configuration (`lib/auth.ts`)
|
||||
|
||||
- [ ] Update `authorize` to store `refresh_token` from Backend response.
|
||||
- [ ] Implement `refreshToken` rotation logic in `jwt` callback:
|
||||
- Check if token is expired.
|
||||
@@ -21,13 +25,16 @@ Finalize frontend setup and implement robust Authentication connecting to the Ne
|
||||
- Handle refresh errors (Force sign out).
|
||||
|
||||
### 3. Login Page (`app/(auth)/login/page.tsx`)
|
||||
|
||||
- [ ] Polish Error Handling (Use Toasts instead of alerts).
|
||||
- [ ] Ensure redirect works correctly.
|
||||
|
||||
### 4. Middleware (`middleware.ts`)
|
||||
|
||||
- [ ] Verify middleware protects dashboard routes.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
1. **Manual Test:** Login with valid credentials.
|
||||
2. **Inspection:** Check LocalStorage/Cookies (NextAuth session cookie).
|
||||
3. **Token Rotation:** Wait for short access token expiry (if configurable) or manually invalidate, and verify seamless refresh.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
**Objective:** Enhance Security and Documentation
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This session focused on completing Priority 2 (P2) tasks for the Backend v1.4.3. All P2 objectives were met, including API documentation, secure session management, observability, and API hardening.
|
||||
|
||||
**Note:** While P2 features are complete and verified by code review, the `pnpm build` process is currently failing due to pre-existing issues in P0 modules (Casl Ability & Workflow DSL) that were outside the scope of this session. These build errors must be addressed in the next session (P0 Urgent).
|
||||
@@ -12,6 +13,7 @@ This session focused on completing Priority 2 (P2) tasks for the Backend v1.4.3.
|
||||
## Completed Tasks
|
||||
|
||||
### ✅ P2-1: Swagger API Documentation
|
||||
|
||||
- **Objective:** Improve API discoverability.
|
||||
- **Changes:**
|
||||
- Configured `SwaggerModule` at `/docs`.
|
||||
@@ -19,6 +21,7 @@ This session focused on completing Priority 2 (P2) tasks for the Backend v1.4.3.
|
||||
- Decorated DTOs with `@ApiProperty` for schema clarity.
|
||||
|
||||
### ✅ P2-2: Refresh Token Mechanism
|
||||
|
||||
- **Objective:** Secure session management implementation (ADR-016).
|
||||
- **Changes:**
|
||||
- Created `RefreshToken` entity (hashed tokens).
|
||||
@@ -30,6 +33,7 @@ This session focused on completing Priority 2 (P2) tasks for the Backend v1.4.3.
|
||||
- Exposed `POST /auth/refresh` endpoint.
|
||||
|
||||
### ✅ P2-3: Prometheus Metrics
|
||||
|
||||
- **Objective:** System observability.
|
||||
- **Changes:**
|
||||
- Integrated `@willsoto/nestjs-prometheus` and opened `/metrics`.
|
||||
@@ -38,6 +42,7 @@ This session focused on completing Priority 2 (P2) tasks for the Backend v1.4.3.
|
||||
- Refactored `MonitoringModule` for modularity.
|
||||
|
||||
### ✅ P2-4: Rate Limiting & Security Headers
|
||||
|
||||
- **Objective:** API Hardening.
|
||||
- **Changes:**
|
||||
- **Throttler:** Verified global rate limit (100/min) and strict login limit (5/min).
|
||||
@@ -56,6 +61,7 @@ The following build errors were identified but deferred as they belong to P0 sco
|
||||
**Action Plan:** These must be fixed immediately in the next session to restore build stability.
|
||||
|
||||
## Artifacts Created
|
||||
|
||||
- `specs/09-history/2025-12-06_p2-completion.md` (This file)
|
||||
- `src/common/auth/entities/refresh-token.entity.ts`
|
||||
- `src/modules/monitoring/` (Refactored)
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
# P3-1: Frontend Admin Panel Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a functional Admin Panel for User and Master Data Management, connected to existing Backend APIs.
|
||||
|
||||
## Scope
|
||||
|
||||
1. **Admin Layout**: Sidebar navigation and layout structure at `/app/(admin)`.
|
||||
2. **User Management**:
|
||||
* List Users (`GET /users`) with pagination/filtering.
|
||||
* Create/Edit User (`POST /users`, `PATCH /users/:id`).
|
||||
* Assign Roles (`POST /users/assign-role`).
|
||||
- List Users (`GET /users`) with pagination/filtering.
|
||||
- Create/Edit User (`POST /users`, `PATCH /users/:id`).
|
||||
- Assign Roles (`POST /users/assign-role`).
|
||||
3. **Organization Management**:
|
||||
* List Organizations (`GET /organizations`).
|
||||
* Create/Edit Organization (`POST`, `PATCH`).
|
||||
- List Organizations (`GET /organizations`).
|
||||
- Create/Edit Organization (`POST`, `PATCH`).
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Admin Layout & Navigation
|
||||
|
||||
- **File**: `app/(admin)/layout.tsx`
|
||||
- **File**: `components/admin/admin-sidebar.tsx`
|
||||
- **Logic**: Ensure only users with `ADMIN` role can access.
|
||||
|
||||
### 2. User Management
|
||||
|
||||
- **Page**: `app/(admin)/admin/users/page.tsx`
|
||||
- **Components**:
|
||||
* `components/admin/users/user-table.tsx` (using `tanstack/react-table`)
|
||||
* `components/admin/users/user-dialog.tsx` (Create/Edit Form with Zod validation)
|
||||
- `components/admin/users/user-table.tsx` (using `tanstack/react-table`)
|
||||
- `components/admin/users/user-dialog.tsx` (Create/Edit Form with Zod validation)
|
||||
|
||||
### 3. Organization Management
|
||||
|
||||
- **Page**: `app/(admin)/admin/organizations/page.tsx`
|
||||
- **Components**:
|
||||
* `components/admin/orgs/org-table.tsx`
|
||||
* `components/admin/orgs/org-dialog.tsx`
|
||||
- `components/admin/orgs/org-table.tsx`
|
||||
- `components/admin/orgs/org-dialog.tsx`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Backend Endpoints: verified (`UserController`, `OrganizationController`).
|
||||
- UI Components: `Table`, `Dialog`, `Form` (Shadcn/UI - already installed).
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Login as Admin.
|
||||
- [ ] Navigate to `/admin/users`.
|
||||
- [ ] Create a new user and verify in DB/List.
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
## ✅ Changes Implemented
|
||||
|
||||
### 1. State Management (Auth Store)
|
||||
|
||||
Created `frontend/lib/stores/auth-store.ts` using **Zustand**.
|
||||
|
||||
- Manages `user`, `token`, and `isAuthenticated` state.
|
||||
- Provides `hasPermission()` and `hasRole()` helpers.
|
||||
- Uses `persist` middleware to save state to LocalStorage.
|
||||
|
||||
### 2. RBAC Component (`<Can />`)
|
||||
|
||||
Created `frontend/components/common/can.tsx`.
|
||||
|
||||
- Conditionally renders children based on permissions.
|
||||
- **Usage:**
|
||||
```tsx
|
||||
@@ -21,18 +25,23 @@ Created `frontend/components/common/can.tsx`.
|
||||
```
|
||||
|
||||
### 3. Login Page Polish
|
||||
|
||||
Refactored `frontend/app/(auth)/login/page.tsx`.
|
||||
|
||||
- **Removed** inline error alerts.
|
||||
- **Added** `sonner` Toasts for success/error messages.
|
||||
- Improved UX with clear loading states and feedback.
|
||||
|
||||
### 4. Global Toaster
|
||||
|
||||
- Installed `sonner` and `next-themes`.
|
||||
- Created `frontend/components/ui/sonner.tsx` (Shadcn/UI wrapper).
|
||||
- Added `<Toaster />` to `frontend/app/layout.tsx`.
|
||||
|
||||
### 5. Session Sync (`AuthSync`)
|
||||
|
||||
Created `frontend/components/auth/auth-sync.tsx`.
|
||||
|
||||
- Listens to NextAuth session changes.
|
||||
- Updates Zustand `auth-store` automatically.
|
||||
- Ensures `useAuthStore` is always in sync with server session.
|
||||
@@ -54,13 +63,14 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
- Go to `/login`.
|
||||
- Try invalid password -> Expect **Toast Error**.
|
||||
- Try valid password -> Expect **Toast Success** and redirect.
|
||||
2. **Permission Test:**
|
||||
4. **Permission Test:**
|
||||
- Use the `<Can />` component in any page.
|
||||
- `useAuthStore.getState().setAuth(...)` with a user role.
|
||||
- Verify elements show/hide correctly.
|
||||
|
||||
## 📸 Screenshots
|
||||
*(No visual artifacts generated in this session, please run locally to verify UI)*
|
||||
|
||||
_(No visual artifacts generated in this session, please run locally to verify UI)_
|
||||
|
||||
# Correspondence Module Integration (TASK-FE-004)
|
||||
|
||||
@@ -70,11 +80,13 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🚀 Changes Implemented
|
||||
|
||||
### 1. Service Layer & API
|
||||
|
||||
- **Removed:** `frontend/lib/api/correspondences.ts` (Mock API)
|
||||
- **Updated:** `frontend/lib/services/master-data.service.ts` to include `getOrganizations`
|
||||
- **Verified:** `frontend/lib/services/correspondence.service.ts` uses `apiClient` correctly.
|
||||
|
||||
### 2. State Management (TanStack Query)
|
||||
|
||||
- **Created:** `frontend/hooks/use-correspondence.ts`
|
||||
- `useCorrespondences`: Fetch list with pagination
|
||||
- `useCreateCorrespondence`: Mutation for creation
|
||||
@@ -82,22 +94,26 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
- `useOrganizations`: Fetch master data for dropdowns
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
- **List Page:** Converted to Client Component using `useCorrespondences`.
|
||||
- **Create Form:** Integrated `useCreateCorrespondence` and `useOrganizations` for real data submission and dynamic dropdowns.
|
||||
|
||||
## 🧪 Verification Steps (Manual)
|
||||
|
||||
### 1. Verify API Connection
|
||||
|
||||
- Ensure Backend is running.
|
||||
- Go to `/correspondences`.
|
||||
- Check Network Tab: Request to `GET /api/correspondences` should appear.
|
||||
|
||||
### 2. Verify Master Data
|
||||
|
||||
- Go to `/correspondences/new`.
|
||||
- Check "From/To Organization" dropdowns.
|
||||
- They should populate from `GET /api/organizations`.
|
||||
|
||||
### 3. Verify Create Workflow
|
||||
|
||||
- Fill form and Submit.
|
||||
- Toast success should appear.
|
||||
- Redirect to list page.
|
||||
@@ -111,15 +127,18 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🚀 Changes Implemented
|
||||
|
||||
### 1. Service Layer & API
|
||||
|
||||
- **Removed:** `frontend/lib/api/rfas.ts` (Mock API)
|
||||
- **Updated:** `frontend/lib/services/master-data.service.ts` to include `getDisciplines`.
|
||||
|
||||
### 2. State Management (TanStack Query)
|
||||
|
||||
- **Created:** `frontend/hooks/use-rfa.ts`
|
||||
- `useRFAs`, `useRFA`, `useCreateRFA`, `useProcessRFA`.
|
||||
- **Updated:** `frontend/hooks/use-master-data.ts` to include `useDisciplines`.
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
- **List Page (`/rfas/page.tsx`):** Converted to Client Component using `useRFAs`.
|
||||
- **Create Form:** Uses `useCreateRFA` and `useDisciplines`.
|
||||
- **Detail View:** Uses `useRFA` and `useProcessRFA` (for Approve/Reject).
|
||||
@@ -128,16 +147,19 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🧪 Verification Steps (Manual)
|
||||
|
||||
### 1. RFA List
|
||||
|
||||
- Go to `/rfas`.
|
||||
- Pagination and List should load from Backend.
|
||||
|
||||
### 2. Create RFA
|
||||
|
||||
- Go to `/rfas/new`.
|
||||
- "Discipline" dropdown should load real data.
|
||||
- "Contract" defaults to ID 1 (mock/placeholder in code).
|
||||
- Fill items and Submit. Success Toast should appear.
|
||||
|
||||
### 3. Workflow Action
|
||||
|
||||
- Open an RFA Detail (`/rfas/1`).
|
||||
- Click "Approve" or "Reject".
|
||||
- Dialog appears, enter comment, confirm.
|
||||
@@ -151,26 +173,31 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🚀 Changes Implemented
|
||||
|
||||
### 1. Service Layer & API
|
||||
|
||||
- **Removed:** `frontend/lib/api/search.ts` (Mock API)
|
||||
- **Updated:** `frontend/lib/services/search.service.ts` to include `suggest` (via `search` endpoint with limit).
|
||||
|
||||
### 2. Custom Hooks
|
||||
|
||||
- **Created:** `frontend/hooks/use-search.ts`
|
||||
- `useSearch`: For full search results with caching.
|
||||
- `useSearchSuggestions`: For autocomplete in global search.
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
- **Global Search:** Connected to `useSearchSuggestions`. Shows real-time results from backend.
|
||||
- **Search Page:** Connected to `useSearch`. Supports filtering (Type, Status) via API parameters.
|
||||
|
||||
## 🧪 Verification Steps (Manual)
|
||||
|
||||
### 1. Global Search
|
||||
|
||||
- Type a keyword in the top header search bar (e.g., "test" or "LCBP3").
|
||||
- Suggestions should dropdown after 300ms debounce.
|
||||
- Clicking a suggestion should navigate to Detail page.
|
||||
|
||||
### 2. Advanced Search
|
||||
|
||||
- Press Enter in Global Search or go to `/search?q=...`.
|
||||
- Results list should appear.
|
||||
- Apply "Document Type" filter (e.g., RFA).
|
||||
@@ -184,26 +211,31 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🚀 Changes Implemented
|
||||
|
||||
### 1. Service Layer & API
|
||||
|
||||
- **Removed:** `frontend/lib/api/drawings.ts` (Mock API)
|
||||
- **Verified:** `contract-drawing.service.ts` and `shop-drawing.service.ts` are active.
|
||||
|
||||
### 2. Custom Hooks
|
||||
|
||||
- **Created:** `frontend/hooks/use-drawing.ts`
|
||||
- `useDrawings(type)`: Unified hook that switches logic based on `CONTRACT` or `SHOP` type.
|
||||
- `useCreateDrawing(type)`: Unified mutation for uploading drawings.
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
- **Drawing List:** Uses `useDrawings` to fetch real data. Supports switching tabs (Contract vs Shop).
|
||||
- **Upload Form:** Uses `useCreateDrawing` and `useDisciplines` (from master data). Handles file selection.
|
||||
|
||||
## 🧪 Verification Steps (Manual)
|
||||
|
||||
### 1. Drawing List
|
||||
|
||||
- Go to `/drawings`.
|
||||
- Switch between "Contract Drawings" and "Shop Drawings" tabs.
|
||||
- Ensure correct data (or empty state) loads for each.
|
||||
|
||||
### 2. Upload Drawing
|
||||
|
||||
- Click "Upload Drawing".
|
||||
- Select "Contract Drawing".
|
||||
- Fill in required fields (Discipline must load from API).
|
||||
@@ -219,10 +251,12 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🚀 Changes Implemented
|
||||
|
||||
### 1. Services & Hooks
|
||||
|
||||
- **Created:** `dashboard.service.ts` and `notification.service.ts`.
|
||||
- **Created:** `use-dashboard.ts` (Stats, Activity, Pending) and `use-notification.ts` (Unread, MarkRead).
|
||||
|
||||
### 2. UI Updates
|
||||
|
||||
- **Dashboard Page:** Converted to Client Component to use parallel querying hooks.
|
||||
- **Widgets:** `StatsCards`, `RecentActivity`, `PendingTasks` updated to accept `isLoading` props and show skeletons.
|
||||
- **Notifications:** Dropdown now fetches real unread count and marks as read on click.
|
||||
@@ -230,11 +264,13 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🧪 Verification Steps (Manual)
|
||||
|
||||
### 1. Dashboard
|
||||
|
||||
- Navigate to `/dashboard` (or root `/`).
|
||||
- Verify Stats, Activity, and Tasks load (skeletons show briefly).
|
||||
- Check data accuracy against backend state.
|
||||
|
||||
### 2. Notifications
|
||||
|
||||
- Check the Bell icon in the top bar.
|
||||
- Badge should show unread count (if any).
|
||||
- Click to open dropdown -> list should load.
|
||||
@@ -248,31 +284,37 @@ Created `frontend/components/auth/auth-sync.tsx`.
|
||||
## 🚀 Changes Implemented
|
||||
|
||||
### 1. Routes & Layout
|
||||
|
||||
- **Route Group:** `app/(admin)` for isolated admin context.
|
||||
- **Layout:** `AdminLayout` enforces Role Check (server-side).
|
||||
- **Sidebar:** `AdminSidebar` for navigation (Users, Logs, Settings).
|
||||
|
||||
### 2. User Management
|
||||
|
||||
- **Page:** `/admin/users` lists all users with filtering.
|
||||
- **Features:** Create, Edit, Delete (Soft), Role Assignment.
|
||||
- **Components:** `UserDialog` handles form with validation.
|
||||
|
||||
### 3. Audit Logs
|
||||
|
||||
- **Page:** `/admin/audit-logs` shows system activity.
|
||||
|
||||
## 🧪 Verification Steps (Manual)
|
||||
|
||||
### 1. Access Control
|
||||
|
||||
- Login as non-admin -> Try `/admin/users` -> Should redirect to home.
|
||||
- Login as Admin -> Should access.
|
||||
|
||||
### 2. User CRUD
|
||||
|
||||
- Go to `/admin/users`.
|
||||
- Add User "Test Admin". Assign "ADMIN" role.
|
||||
- Edit User.
|
||||
- Delete User.
|
||||
|
||||
### 3. Audit Logs
|
||||
|
||||
- Perform actions.
|
||||
- Go to `/admin/audit-logs`.
|
||||
- Verify new logs appear.
|
||||
|
||||
@@ -1,48 +1,57 @@
|
||||
# Session Log: Admin Console Fixes
|
||||
|
||||
Date: 2025-12-11
|
||||
|
||||
## Overview
|
||||
|
||||
This session focused on debugging and resolving critical display and functionality issues in the Admin Console. Major fixes included Data integration for Document Numbering, RBAC Matrix functionality, and resolving data unwrapping issues for Active Sessions and Logs.
|
||||
|
||||
## Resolved Issues
|
||||
|
||||
### 1. Tag Management
|
||||
|
||||
- **Issue:** 404 Error when accessing system tags.
|
||||
- **Cause:** Incorrect API endpoint (`/tags` vs `/master/tags`).
|
||||
- **Resolution:** Updated frontend service to use the correct `/master` prefix.
|
||||
|
||||
### 2. Document Numbering
|
||||
|
||||
- **Issue:** Project Selection dropdown used hardcoded mock data.
|
||||
- **Cause:** `PROJECTS` constant in component.
|
||||
- **Resolution:** Implemented `useProjects` hook to fetch dynamic project list from backend.
|
||||
|
||||
### 3. RBAC Matrix
|
||||
|
||||
- **Issue:** Permission checkboxes were all empty.
|
||||
- **Cause:** `UserService.findAllRoles` did not load the `permissions` relation.
|
||||
- **Resolution:**
|
||||
- Updated `UserService` to eager load relations.
|
||||
- Implemented `updateRolePermissions` in backend.
|
||||
- Added `PATCH` endpoint for saving changes.
|
||||
- Updated `UserService` to eager load relations.
|
||||
- Implemented `updateRolePermissions` in backend.
|
||||
- Added `PATCH` endpoint for saving changes.
|
||||
|
||||
### 4. Active Sessions
|
||||
|
||||
- **Issue:** List "No results" and missing user names.
|
||||
- **Cause:**
|
||||
- Property mismatch (`first_name` vs `firstName`).
|
||||
- Frontend failed to unwrap `response.data.data` (Interceptor behavior).
|
||||
- Property mismatch (`first_name` vs `firstName`).
|
||||
- Frontend failed to unwrap `response.data.data` (Interceptor behavior).
|
||||
- **Resolution:**
|
||||
- Aligned backend/frontend naming convention.
|
||||
- Updated `sessionService` to handle wrapped response data.
|
||||
- Improved backend date comparison robustness.
|
||||
- Aligned backend/frontend naming convention.
|
||||
- Updated `sessionService` to handle wrapped response data.
|
||||
- Improved backend date comparison robustness.
|
||||
|
||||
### 5. Numbering Logs
|
||||
|
||||
- **Issue:** Logs table empty.
|
||||
- **Cause:** Same data unwrapping issue as Active Sessions.
|
||||
- **Resolution:** Updated `logService` in `system-logs/numbering/page.tsx`.
|
||||
|
||||
### 6. Missing Permissions (Advisory)
|
||||
|
||||
- **Issue:** 403 Forbidden on Logs page.
|
||||
- **Cause:** `system.view_logs` permission missing from user role.
|
||||
- **Resolution:** Advised user to use the newly fixed RBAC Matrix to assign the permission.
|
||||
|
||||
## Verification
|
||||
|
||||
All issues were verified by manual testing and confirming correct data display in the Admin Console. Backend logs were used to debug the Active Sessions data flow.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
# Admin Panel UX Refactoring (2025-12-11)
|
||||
|
||||
**Objectives:**
|
||||
|
||||
- Standardize UX across Admin modules (Loading Skeletons, Alert Dialogs).
|
||||
- Fix specific display bugs in Reference Data.
|
||||
- Improve Admin Dashboard.
|
||||
|
||||
**Achievements:**
|
||||
|
||||
1. **Dashboard Upgrade:**
|
||||
- Replaced `/admin` redirect with a proper Dashboard page showing stats and quick links.
|
||||
- Added `Skeleton` loading for stats.
|
||||
@@ -13,9 +15,9 @@
|
||||
2. **Consistency Improvements:**
|
||||
- **Modules:** Organizations, Users, Projects, Contracts.
|
||||
- **Changes:**
|
||||
- Replaced "Loading..." text with `Skeleton` rows.
|
||||
- Replaced `window.confirm()` with `AlertDialog` (Shadcn UI).
|
||||
- Fixed `any` type violations in Users, Projects, Contracts.
|
||||
- Replaced "Loading..." text with `Skeleton` rows.
|
||||
- Replaced `window.confirm()` with `AlertDialog` (Shadcn UI).
|
||||
- Fixed `any` type violations in Users, Projects, Contracts.
|
||||
|
||||
3. **Reference Data Overhaul:**
|
||||
- Refactored `GenericCrudTable` to include Skeleton loading and AlertDialogs natively.
|
||||
@@ -24,6 +26,7 @@
|
||||
- **Fixed Bug:** "Drawing Categories" page displaying incorrect columns (fixed DTO matching).
|
||||
|
||||
**Modified Files:**
|
||||
|
||||
- `frontend/app/(admin)/admin/page.tsx`
|
||||
- `frontend/app/(admin)/admin/organizations/page.tsx`
|
||||
- `frontend/app/(admin)/admin/users/page.tsx`
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
## 🛠 Fixes & Changes
|
||||
|
||||
### 1. Revision-Based List View
|
||||
|
||||
- **Issue:** The Correspondence List was displaying one row per Document, hiding revision history.
|
||||
- **Fix:** Refactored `CorrespondenceService.findAll` to query `CorrespondenceRevision` as the primary entity.
|
||||
- **Outcome:** The list now displays every revision (e.g., Doc-001 Rev A, Doc-001 Rev B) as separate rows. Added "Rev" column to the UI.
|
||||
|
||||
### 2. Correspondence Detail Page
|
||||
|
||||
- **Issue:** Detail page was not displaying Subject/Description correctly (showing "-") because it wasn't resolving the `currentRevision` correctly or receiving unwrapped data.
|
||||
- **Fix:**
|
||||
- Updated `CorrespondenceDetail` to explicitly try finding `isCurrent` revision or fallback to index 0.
|
||||
@@ -18,6 +20,7 @@
|
||||
- **Outcome:** Detail page now correctly shows Subject, Description, and Status from the current revision.
|
||||
|
||||
### 3. Edit Functionality
|
||||
|
||||
- **Issue:** Clicking "Edit" led to a 404/Blank page.
|
||||
- **Fix:**
|
||||
- Created `app/(dashboard)/correspondences/[id]/edit/page.tsx`.
|
||||
@@ -25,6 +28,7 @@
|
||||
- **Outcome:** Users can now edit existing DRAFT correspondences.
|
||||
|
||||
## 📂 Modified Files
|
||||
|
||||
- `backend/src/modules/correspondence/correspondence.service.ts`
|
||||
- `frontend/types/correspondence.ts`
|
||||
- `frontend/components/correspondences/list.tsx`
|
||||
@@ -34,6 +38,7 @@
|
||||
- `frontend/app/(dashboard)/correspondences/[id]/edit/page.tsx` (Created)
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
- Validated List View shows revisions.
|
||||
- Validated Detail View loads data.
|
||||
- Validated Edit Page loads data and submits updates.
|
||||
|
||||
@@ -23,6 +23,7 @@ Implement frontend testing infrastructure and unit tests per `specs/03-implement
|
||||
| `frontend/package.json` | Added test scripts: `test`, `test:watch`, `test:coverage` |
|
||||
|
||||
**Dependencies Installed:**
|
||||
|
||||
- `vitest`
|
||||
- `@vitejs/plugin-react`
|
||||
- `@testing-library/react`
|
||||
|
||||
@@ -15,8 +15,8 @@ Review frontend integration status and fix minor issues in Correspondences, RFAs
|
||||
|
||||
Verified that all 3 core modules are properly integrated with Backend APIs:
|
||||
|
||||
| Module | Service | Hook | API Endpoint | Status |
|
||||
| ----------------- | ----------------------------- | ----------------------- | -------------------- | ---------- |
|
||||
| Module | Service | Hook | API Endpoint | Status |
|
||||
| ----------------- | ----------------------------- | ----------------------- | -------------------- | ----------- |
|
||||
| Correspondences | `correspondence.service.ts` | `use-correspondence.ts` | `/correspondences` | ✅ Real API |
|
||||
| RFAs | `rfa.service.ts` | `use-rfa.ts` | `/rfas` | ✅ Real API |
|
||||
| Contract Drawings | `contract-drawing.service.ts` | `use-drawing.ts` | `/drawings/contract` | ✅ Real API |
|
||||
@@ -25,6 +25,7 @@ Verified that all 3 core modules are properly integrated with Backend APIs:
|
||||
### 2. Minor Issues Fixed ✅
|
||||
|
||||
#### 2.1 `components/drawings/list.tsx`
|
||||
|
||||
- **Issue:** Hardcoded `projectId: 1`
|
||||
- **Fix:** Added optional `projectId` prop to `DrawingListProps` interface
|
||||
|
||||
@@ -41,6 +42,7 @@ interface DrawingListProps {
|
||||
```
|
||||
|
||||
#### 2.2 `hooks/use-drawing.ts`
|
||||
|
||||
- **Issue:** `any` types in multiple places
|
||||
- **Fix:** Added proper types
|
||||
|
||||
@@ -59,6 +61,7 @@ interface DrawingListProps {
|
||||
```
|
||||
|
||||
#### 2.3 `hooks/use-correspondence.ts`
|
||||
|
||||
- **Issue:** `any` types and missing mutations
|
||||
- **Fix:**
|
||||
- Added `ApiError` type for error handling
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
# Schema v1.6.0 Migration & Document Number Fixes (2025-12-13)
|
||||
|
||||
## Task Summary
|
||||
|
||||
This session focused on completing the migration to Schema v1.6.0 (Correspondence/RFA shared PK) and resolving critical bugs in the Document Numbering system.
|
||||
|
||||
### Status
|
||||
|
||||
- **Schema Migration**: Completed (Backend & Frontend)
|
||||
- **Document Numbering**:
|
||||
- Preview Fixed (Recipient Code resolution)
|
||||
- Creation Fixed (Source data mapping)
|
||||
- Update Logic Fixed (Auto-regeneration on Draft edit)
|
||||
- Preview Fixed (Recipient Code resolution)
|
||||
- Creation Fixed (Source data mapping)
|
||||
- Update Logic Fixed (Auto-regeneration on Draft edit)
|
||||
|
||||
## Walkthrough & Changes
|
||||
|
||||
### 1. Correspondence Module
|
||||
|
||||
- **New Entity**: `CorrespondenceRecipient` to handle multiple recipients (TO/CC).
|
||||
- **Entity Update**: `Correspondence` now has a `recipients` relation.
|
||||
- **Entity Update**: `CorrespondenceRevision` renamed `title` to `subject`, added `body`, `remarks`, `dueDate`, `schemaVersion`.
|
||||
@@ -20,12 +23,14 @@ This session focused on completing the migration to Schema v1.6.0 (Correspondenc
|
||||
- **DTO Update**: `CreateCorrespondenceDto` updated to support proper fields.
|
||||
|
||||
### 2. RFA Module
|
||||
|
||||
- **Shared Primary Key**: `Rfa` entity now shares PK with `Correspondence`.
|
||||
- **Revision Update**: `RfaRevision` removed `correspondenceId` (access via `rfa.correspondence.id`), renamed `title` to `subject`, added new fields.
|
||||
- **Item Update**: `RfaItem` FK column renamed to `rfa_revision_id`.
|
||||
- **Service Update**: Only `RfaService` logic updated to handle shared PK and new field mappings. `findAll` query updated to join via `rfa.correspondence`.
|
||||
|
||||
### 3. Frontend Adaptation
|
||||
|
||||
- **Type Definitions**: Updated `CorrespondenceRevision` and `RFA` types to match schema v1.6.0.
|
||||
- **Form Components**:
|
||||
- `CorrespondenceForm`: Renamed `title` to `subject`, added `body`, `remarks`, `dueDate`.
|
||||
@@ -36,27 +41,33 @@ This session focused on completing the migration to Schema v1.6.0 (Correspondenc
|
||||
## Bug Fixes & Refinements (Session 2)
|
||||
|
||||
### Document Number Preview
|
||||
|
||||
- **Issue**: Preview showed `--` for recipient code.
|
||||
- **Fix**:
|
||||
- Implemented `customTokens` support in `DocumentNumberingService`.
|
||||
- updated `CorrespondenceService.previewNextNumber` to manually resolve recipient code from `OrganizationRepository`.
|
||||
- Implemented `customTokens` support in `DocumentNumberingService`.
|
||||
- updated `CorrespondenceService.previewNextNumber` to manually resolve recipient code from `OrganizationRepository`.
|
||||
|
||||
### Correspondence Creation
|
||||
|
||||
- **Issue**: Generated document number used incorrect placeholder.
|
||||
- **Fix**: Updated `create` method to extract recipient from `recipients` array instead of legacy `details` field.
|
||||
|
||||
### Edit Page Loading
|
||||
|
||||
- **Issue**: "Failed to load" error on Edit page.
|
||||
- **Fix**: Corrected TypeORM relation path in `CorrespondenceService.findOne` from `recipients.organization` to properties `recipients.recipientOrganization`.
|
||||
|
||||
### Document Number Auto-Update
|
||||
|
||||
- **Feature**: Automatically regenerate document number when editing a Draft.
|
||||
- **Implementation**: logic added to `update` method to re-calculate number if `type`, `discipline`, `project`, or `recipient` changes.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- **Backend Tests**: `correspondence.service.spec.ts` passed.
|
||||
- **Frontend Tests**: All 9 suites (113 tests) passed.
|
||||
- **Manual Verification**: Verified Preview, Creation, and Edit flows.
|
||||
|
||||
## Future Tasks
|
||||
|
||||
- [ ] **Data Cleanup**: Migration script to fix existing document numbers with missing recipient codes (e.g., `คคง.--0001-2568` -> `คคง.-XYZ-0001-2568`).
|
||||
|
||||
@@ -1,43 +1,50 @@
|
||||
# Document Numbering Refactoring - 2025-12-18
|
||||
|
||||
## Overview
|
||||
|
||||
Refactored the `DocumentNumberingService` in the backend to split responsibilities into dedicated services (`CounterService`, `ReservationService`) and updated the `DocumentNumberCounter` entity to match the v1.7.0 schema.
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Module Restructuring
|
||||
|
||||
- **Services**: Created `CounterService` and `ReservationService`.
|
||||
- **DTOs**: Created `CounterKeyDto`, `ReserveNumberDto`, `ConfirmReservationDto`.
|
||||
- **Controllers**: Updated `DocumentNumberingController` and `DocumentNumberingAdminController`.
|
||||
|
||||
### 2. Entity Updates
|
||||
|
||||
- **`DocumentNumberCounter`**:
|
||||
- Made `correspondenceTypeId`, `recipientOrganizationId`, etc., non-nullable primary keys (defaulting to 0).
|
||||
- Added `resetScope` with length 20.
|
||||
- Made `correspondenceTypeId`, `recipientOrganizationId`, etc., non-nullable primary keys (defaulting to 0).
|
||||
- Added `resetScope` with length 20.
|
||||
- **`DocumentNumberReservation`**: Created for two-phase commit reservation logic.
|
||||
|
||||
### 3. Service Logic
|
||||
|
||||
- **`CounterService`**:
|
||||
- Handles atomic counter increment.
|
||||
- Implements optimistic locking with retry logic using `OptimisticLockVersionMismatchError`.
|
||||
- Handles atomic counter increment.
|
||||
- Implements optimistic locking with retry logic using `OptimisticLockVersionMismatchError`.
|
||||
- **`ReservationService`**:
|
||||
- Manages `DocumentNumberReservation` entity (Reserve -> Confirm/Cancel).
|
||||
- Removes unused `userId` from confirmation/cancellation logic.
|
||||
- Manages `DocumentNumberReservation` entity (Reserve -> Confirm/Cancel).
|
||||
- Removes unused `userId` from confirmation/cancellation logic.
|
||||
- **`DocumentNumberingService`**:
|
||||
- Delegates counter logic to `CounterService`.
|
||||
- Delegates reservation logic to `ReservationService`.
|
||||
- Corrected property mapping (e.g., `originatorOrganizationId`).
|
||||
- Fixed `resolveDisciplineCode` to use `disciplineCode` column.
|
||||
- Delegates counter logic to `CounterService`.
|
||||
- Delegates reservation logic to `ReservationService`.
|
||||
- Corrected property mapping (e.g., `originatorOrganizationId`).
|
||||
- Fixed `resolveDisciplineCode` to use `disciplineCode` column.
|
||||
|
||||
## Verification Results
|
||||
|
||||
### Automated Tests
|
||||
|
||||
Ran unit tests for `DocumentNumberingService`:
|
||||
|
||||
```bash
|
||||
npm test modules/document-numbering/document-numbering.service.spec.ts
|
||||
```
|
||||
|
||||
**Result:**
|
||||
|
||||
```
|
||||
PASS src/modules/document-numbering/document-numbering.service.spec.ts
|
||||
DocumentNumberingService
|
||||
@@ -51,5 +58,6 @@ Tests: 3 passed, 3 total
|
||||
```
|
||||
|
||||
### Manual Verification Steps
|
||||
|
||||
1. **Generate Number**: Call `POST /document-numbering/preview` (mapped to `previewNumber`).
|
||||
2. **Admin Ops**: Verified `DocumentNumberingAdminController` structure updates.
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
## TABLE contract_drawings:
|
||||
- change sub_cat_id -> map_cat_id
|
||||
- add volume_page INT COMMENT 'หน้าที่',
|
||||
|
||||
- change sub_cat_id -> map_cat_id
|
||||
- add volume_page INT COMMENT 'หน้าที่',
|
||||
|
||||
## TABLE contract_drawing_subcat_cat_maps
|
||||
- alter id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
|
||||
- alter id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'ID ของตาราง',
|
||||
|
||||
## TABLE shop_drawing_sub_categories
|
||||
- delete main_category_id
|
||||
- add project_id INT NOT NULL COMMENT 'โครงการ',
|
||||
|
||||
- delete main_category_id
|
||||
- add project_id INT NOT NULL COMMENT 'โครงการ',
|
||||
|
||||
## TABLE shop_drawing_main_categories
|
||||
- add project_id INT NOT NULL COMMENT 'โครงการ',
|
||||
|
||||
- add project_id INT NOT NULL COMMENT 'โครงการ',
|
||||
|
||||
## TABLE shop_drawings
|
||||
- delete title
|
||||
|
||||
- delete title
|
||||
|
||||
## TABLE shop_drawing_revisions
|
||||
- add title
|
||||
- add legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
|
||||
|
||||
- add title
|
||||
- add legacy_drawing_number VARCHAR(100) NULL COMMENT 'เลขที่เดิมของ Shop Drawing',
|
||||
|
||||
## TABLE asbuilt_drawings
|
||||
|
||||
## TABLE asbuilt_drawing_revisions
|
||||
|
||||
## TABLE asbuilt_revision_shop_revisions_refs
|
||||
|
||||
@@ -1,41 +1,47 @@
|
||||
# Session History - 2025-12-23: Document Numbering Form Refactoring
|
||||
|
||||
## Objective
|
||||
|
||||
Refactor and debug the "Test Number Generation" (Template Tester) form to support real API validation and master data integration.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Frontend Refactoring (`template-tester.tsx`)
|
||||
|
||||
- **Master Data Integration**: Replaced manual text inputs with `Select` components for Originator, Recipient, Document Type, and Discipline.
|
||||
- **Dynamic Data Hook**:
|
||||
- Integrated `useOrganizations`, `useCorrespondenceTypes`, and `useDisciplines`.
|
||||
- Fixed empty Discipline list by adding `useContracts` to fetch active contracts for the project and deriving `contractId` dynamically.
|
||||
- Integrated `useOrganizations`, `useCorrespondenceTypes`, and `useDisciplines`.
|
||||
- Fixed empty Discipline list by adding `useContracts` to fetch active contracts for the project and deriving `contractId` dynamically.
|
||||
- **API Integration**: Switched from mock `generateTestNumber` to backend `previewNumber` endpoint.
|
||||
- **UI Enhancements**:
|
||||
- Added "Default (All Types)" and "None" options to dropdowns.
|
||||
- Improved error feedback with a visible error card if generation fails.
|
||||
- Added "Default (All Types)" and "None" options to dropdowns.
|
||||
- Improved error feedback with a visible error card if generation fails.
|
||||
- **Type Safety**:
|
||||
- Resolved multiple lint errors (`Unexpected any`, missing properties).
|
||||
- Updated `SearchOrganizationDto` in `organization.dto.ts` to include `isActive`.
|
||||
- Resolved multiple lint errors (`Unexpected any`, missing properties).
|
||||
- Updated `SearchOrganizationDto` in `organization.dto.ts` to include `isActive`.
|
||||
|
||||
### 2. Backend API Harmonization
|
||||
|
||||
- **DTO Updates**:
|
||||
- Refactored `PreviewNumberDto` to use `originatorId` and `typeId` (aligned with frontend naming).
|
||||
- Added `@Type(() => Number)` and `@IsInt()` to ensure proper payload transformation.
|
||||
- Refactored `PreviewNumberDto` to use `originatorId` and `typeId` (aligned with frontend naming).
|
||||
- Added `@Type(() => Number)` and `@IsInt()` to ensure proper payload transformation.
|
||||
- **Service Logic**:
|
||||
- Fixed `CounterService` mapping to correctly use the entity property `originatorId` instead of the DTO naming `originatorOrganizationId` in WHERE clauses and creation logic.
|
||||
- Updated `DocumentNumberingController` to map the new DTO properties.
|
||||
- Fixed `CounterService` mapping to correctly use the entity property `originatorId` instead of the DTO naming `originatorOrganizationId` in WHERE clauses and creation logic.
|
||||
- Updated `DocumentNumberingController` to map the new DTO properties.
|
||||
|
||||
### 3. Troubleshooting & Reversion
|
||||
|
||||
- **Issue**: "Format Preview" was reported as missing.
|
||||
- **Action**: Attempted a property rename from `formatTemplate` to `formatString` across the frontend based on database column naming.
|
||||
- **Result**: This caused the entire Document Numbering page to fail (UI became empty) because the backend entity still uses the property name `formatTemplate`.
|
||||
- **Resolution**: Reverted all renaming changes back to `formatTemplate`. The initial "missing" issue was resolved by ensuring proper prop passing and data loading.
|
||||
|
||||
## Status
|
||||
|
||||
- **Test Generation Form**: Fully functional and integrated with real master data.
|
||||
- **Preview API**: Validated and working with correct database mapping.
|
||||
- **Next Steps**: Monitor for any further data-specific generation errors (e.g., Template format parsing).
|
||||
|
||||
---
|
||||
|
||||
**Reference Task**: [TASK-FE-017-document-numbering-refactor.md](../06-tasks/TASK-FE-017-document-numbering-refactor.md)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
## 1. Summary of Changes
|
||||
|
||||
### Frontend Refactoring
|
||||
|
||||
- **`DrawingUploadForm` Refactor:**
|
||||
- Implemented dynamic validation validation schemas using Zod discriminated unions.
|
||||
- Added support for Contract Drawing fields: `mapCatId`, `volumePage`.
|
||||
@@ -24,15 +25,18 @@
|
||||
## 2. Issues Encountered & Status
|
||||
|
||||
### Resolved
|
||||
|
||||
- Fixed `Unexpected any` lint errors in `DrawingUploadForm` (mostly).
|
||||
- Resolved type mismatches in state identifiers.
|
||||
|
||||
### Known Issues (Pending Fix)
|
||||
|
||||
- **Build Failure**: `pnpm build` failed in `frontend/app/(admin)/admin/numbering/[id]/page.tsx`.
|
||||
- **Error**: `Object literal may only specify known properties, and 'templateId' does not exist in type 'Partial<NumberingTemplate>'.`
|
||||
- **Location**: `numberingApi.saveTemplate({ ...data, templateId: parseInt(params.id) });`
|
||||
- **Cause**: The `saveTemplate` method likely expects a specific DTO that conflicts with the spread `...data` or the explicit `templateId` property assignment. This needs to be addressed in the next session.
|
||||
|
||||
## 3. Next Steps
|
||||
|
||||
- Fix the build error in `admin/numbering/[id]/page.tsx`.
|
||||
- Proceed with full end-to-end testing of the drawing upload flows.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Session History: 2025-12-24 - Document Numbering Fixes
|
||||
|
||||
## Overview
|
||||
|
||||
- **Date:** 2025-12-24
|
||||
- **Duration:** ~2 hours
|
||||
- **Focus:** Document Numbering System - Bug Fixes & Improvements
|
||||
@@ -10,10 +11,13 @@
|
||||
## Changes Made
|
||||
|
||||
### 1. Year Token Format (4-digit)
|
||||
|
||||
**Files:**
|
||||
|
||||
- `backend/src/modules/document-numbering/services/format.service.ts`
|
||||
|
||||
**Changes:**
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
'{YEAR}': year.toString().substring(2), // "25"
|
||||
@@ -27,39 +31,47 @@
|
||||
---
|
||||
|
||||
### 2. TypeScript Field Name Fixes
|
||||
|
||||
**Files:**
|
||||
|
||||
- `backend/src/modules/document-numbering/dto/preview-number.dto.ts`
|
||||
- `backend/src/modules/document-numbering/controllers/document-numbering.controller.ts`
|
||||
- `frontend/lib/api/numbering.ts`
|
||||
- `frontend/components/numbering/template-tester.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
- `originatorId` → `originatorOrganizationId`
|
||||
- `typeId` → `correspondenceTypeId`
|
||||
|
||||
---
|
||||
|
||||
### 3. Generate Test Number Bug Fix
|
||||
|
||||
**Root Cause:**
|
||||
|
||||
1. API client ใช้ NextAuth `getSession()` แต่ token อยู่ใน Zustand localStorage (`auth-storage`)
|
||||
2. Response wrapper mismatch: backend ส่ง `{ data: {...} }` แต่ frontend อ่าน `res.data` โดยตรง
|
||||
|
||||
**Files:**
|
||||
|
||||
- `frontend/lib/api/client.ts` - ดึง token จาก `localStorage['auth-storage']`
|
||||
- `frontend/lib/api/numbering.ts` - แก้ response unwrapping: `res.data.data || res.data`
|
||||
|
||||
---
|
||||
|
||||
### 4. Documentation
|
||||
|
||||
**Files Created/Updated:**
|
||||
|
||||
- `docs/document-numbering-summary.md` - Comprehensive system summary
|
||||
|
||||
---
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Test | Result |
|
||||
| -------------------- | --------- |
|
||||
| Test | Result |
|
||||
| -------------------- | ---------- |
|
||||
| Backend Build | ✅ Pass |
|
||||
| Frontend Build | ✅ Pass |
|
||||
| Generate Test Number | ✅ Working |
|
||||
@@ -67,5 +79,6 @@
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Template ต้องใช้ `{YEAR:BE}` เพื่อแสดงปี พ.ศ. (ไม่ใช่ `{YEAR}`)
|
||||
- สามารถแก้ไข Template ผ่าน Admin > Numbering > Edit Template
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
|
||||
### 1. Backend Entity Updates (Drawing Revision Schema)
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------ | --------------------------------------------------------- |
|
||||
| File | Changes |
|
||||
| ------------------------------------ | ----------------------------------------------------------- |
|
||||
| `shop-drawing-revision.entity.ts` | เพิ่ม `isCurrent`, `createdBy`, `updatedBy`, User relations |
|
||||
| `asbuilt-drawing-revision.entity.ts` | เพิ่ม `isCurrent`, `createdBy`, `updatedBy`, User relations |
|
||||
|
||||
### 2. Frontend Type Updates
|
||||
|
||||
| File | Changes |
|
||||
| --------------------------- | ------------------------------------------------------------------------- |
|
||||
| File | Changes |
|
||||
| --------------------------- | --------------------------------------------------------------------------- |
|
||||
| `frontend/types/drawing.ts` | `DrawingRevision` - เพิ่ม `createdBy`, `updatedBy`, update `isCurrent` type |
|
||||
|
||||
### 3. Admin Panel Frontend (6 pages)
|
||||
@@ -63,12 +63,14 @@
|
||||
## 📁 ไฟล์ที่แก้ไข/สร้างใหม่
|
||||
|
||||
### Backend
|
||||
|
||||
- `backend/src/modules/drawing/entities/shop-drawing-revision.entity.ts` - Modified
|
||||
- `backend/src/modules/drawing/entities/asbuilt-drawing-revision.entity.ts` - Modified
|
||||
- `backend/src/modules/drawing/drawing-master-data.controller.ts` - Rewritten
|
||||
- `backend/src/modules/drawing/drawing-master-data.service.ts` - Rewritten
|
||||
|
||||
### Frontend
|
||||
|
||||
- `frontend/types/drawing.ts` - Modified
|
||||
- `frontend/lib/services/drawing-master-data.service.ts` - **NEW**
|
||||
- `frontend/app/(admin)/admin/drawings/page.tsx` - **NEW**
|
||||
@@ -80,14 +82,15 @@
|
||||
- `frontend/app/(admin)/admin/page.tsx` - Modified
|
||||
|
||||
### Specs
|
||||
|
||||
- `specs/09-history/2025-12-25-drawing-revision-schema-update.md` - Updated (marked complete)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Build Status
|
||||
|
||||
| Component | Status |
|
||||
| --------- | -------- |
|
||||
| Component | Status |
|
||||
| --------- | --------- |
|
||||
| Backend | ✅ Passed |
|
||||
| Frontend | ✅ Passed |
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.sql`, specifically for AS Built Drawings.
|
||||
|
||||
---
|
||||
@@ -15,12 +16,14 @@ Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.
|
||||
### Backend
|
||||
|
||||
#### Entities Updated
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------ | --------------------------------------------------- |
|
||||
| `asbuilt-drawing.entity.ts` | Added `mainCategoryId`, `subCategoryId` + relations |
|
||||
| `asbuilt-drawing-revision.entity.ts` | Added `legacyDrawingNumber` |
|
||||
|
||||
#### New Files Created
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------------------- | ----------------------------------- |
|
||||
| `dto/create-asbuilt-drawing.dto.ts` | Create AS Built with first revision |
|
||||
@@ -30,9 +33,11 @@ Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.
|
||||
| `asbuilt-drawing.controller.ts` | REST controller |
|
||||
|
||||
#### Module Updated
|
||||
|
||||
- `drawing.module.ts` - Registered new entities, service, controller
|
||||
|
||||
#### New API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------------------------- | ------------ |
|
||||
| POST | `/drawings/asbuilt` | Create |
|
||||
@@ -46,12 +51,14 @@ Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.
|
||||
### Frontend
|
||||
|
||||
#### Types Updated
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------- |
|
||||
| `types/drawing.ts` | `AsBuiltDrawing` interface: added `mainCategoryId`, `subCategoryId` |
|
||||
| `types/dto/drawing/asbuilt-drawing.dto.ts` | Added category IDs |
|
||||
|
||||
#### Components Updated
|
||||
|
||||
| File | Changes |
|
||||
| ------------------------------------- | ------------------------------------------------------- |
|
||||
| `components/drawings/upload-form.tsx` | AS_BUILT form: added category selectors, title required |
|
||||
@@ -59,6 +66,7 @@ Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.
|
||||
| `app/(dashboard)/drawings/page.tsx` | Added project selector dropdown |
|
||||
|
||||
#### Hooks Updated
|
||||
|
||||
| File | Changes |
|
||||
| ---------------------- | -------------------------------- |
|
||||
| `hooks/use-drawing.ts` | Fixed toast message for AS_BUILT |
|
||||
@@ -67,14 +75,15 @@ Refactor Drawing module (backend & frontend) to align with `lcbp3-v1.7.0-schema.
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Component | Command | Result |
|
||||
| --------- | ------------ | --------- |
|
||||
| Component | Command | Result |
|
||||
| --------- | ------------ | ---------- |
|
||||
| Backend | `pnpm build` | ✅ Success |
|
||||
| Frontend | `pnpm build` | ✅ Success |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- AS Built Drawings use same category structure as Shop Drawings (`shop_drawing_main_categories`, `shop_drawing_sub_categories`)
|
||||
- No existing data in `asbuilt_drawings` table, no migration needed
|
||||
- Pre-existing lint warnings (`any` types) in `upload-form.tsx` not addressed in this session
|
||||
|
||||
@@ -16,18 +16,22 @@
|
||||
### 1. Schema Updates (`lcbp3-v1.7.0-schema.sql`)
|
||||
|
||||
#### 1.1 เพิ่ม Columns ใน `shop_drawing_revisions`
|
||||
|
||||
```sql
|
||||
is_current BOOLEAN DEFAULT NULL COMMENT '(TRUE = Revision ปัจจุบัน, NULL = ไม่ใช่ปัจจุบัน)'
|
||||
created_by INT COMMENT 'ผู้สร้าง'
|
||||
updated_by INT COMMENT 'ผู้แก้ไขล่าสุด'
|
||||
```
|
||||
|
||||
- เพิ่ม Foreign Keys สำหรับ `created_by` และ `updated_by` ไปยัง `users` table
|
||||
- เพิ่ม `UNIQUE KEY uq_sd_current (shop_drawing_id, is_current)` เพื่อ enforce ว่ามี `is_current = TRUE` ได้แค่ 1 row ต่อ drawing
|
||||
|
||||
#### 1.2 เพิ่ม Columns ใน `asbuilt_drawing_revisions`
|
||||
|
||||
- เหมือนกับ `shop_drawing_revisions`
|
||||
|
||||
#### 1.3 เปลี่ยน Unique Constraint ของ `drawing_number`
|
||||
|
||||
- **เดิม:** `UNIQUE (drawing_number)` - Global uniqueness
|
||||
- **ใหม่:** `UNIQUE (project_id, drawing_number)` - Project-scoped uniqueness
|
||||
|
||||
@@ -42,6 +46,7 @@ CREATE OR REPLACE VIEW vw_asbuilt_drawing_current AS ...
|
||||
```
|
||||
|
||||
**ประโยชน์:**
|
||||
|
||||
- Query ง่ายขึ้นโดยไม่ต้อง JOIN ทุกครั้ง
|
||||
- ตัวอย่าง: `SELECT * FROM vw_shop_drawing_current WHERE project_id = 3`
|
||||
|
||||
@@ -68,19 +73,19 @@ SET sdr.is_current = TRUE;
|
||||
|
||||
MariaDB/MySQL ไม่อนุญาตให้มี duplicate values ใน UNIQUE constraint รวมถึง `FALSE` หลายตัว:
|
||||
|
||||
| `is_current` | ความหมาย | อนุญาตหลายแถว? |
|
||||
| ------------ | -------------- | ----------------------------- |
|
||||
| `TRUE` | Revision ปัจจุบัน | ❌ ไม่ได้ (UNIQUE) |
|
||||
| `NULL` | Revision เก่า | ✅ ได้ (NULL ignored in UNIQUE) |
|
||||
| `FALSE` | Revision เก่า | ❌ ไม่ได้ (จะซ้ำกัน) |
|
||||
| `is_current` | ความหมาย | อนุญาตหลายแถว? |
|
||||
| ------------ | ----------------- | ------------------------------- |
|
||||
| `TRUE` | Revision ปัจจุบัน | ❌ ไม่ได้ (UNIQUE) |
|
||||
| `NULL` | Revision เก่า | ✅ ได้ (NULL ignored in UNIQUE) |
|
||||
| `FALSE` | Revision เก่า | ❌ ไม่ได้ (จะซ้ำกัน) |
|
||||
|
||||
---
|
||||
|
||||
## 📁 ไฟล์ที่แก้ไข
|
||||
|
||||
| ไฟล์ | การเปลี่ยนแปลง |
|
||||
| ----------------------------------------------------- | ------------------------------------ |
|
||||
| `specs/07-database/lcbp3-v1.7.0-schema.sql` | เพิ่ม columns, views, และ constraints |
|
||||
| ไฟล์ | การเปลี่ยนแปลง |
|
||||
| ----------------------------------------------------- | ---------------------------------------- |
|
||||
| `specs/07-database/lcbp3-v1.7.0-schema.sql` | เพิ่ม columns, views, และ constraints |
|
||||
| `specs/07-database/lcbp3-v1.7.0-seed-shopdrawing.sql` | เพิ่ม UPDATE statement สำหรับ is_current |
|
||||
|
||||
---
|
||||
|
||||
@@ -54,14 +54,14 @@
|
||||
|
||||
- ✅ **General Correspondence** (LETTER / MEMO / etc.) → **Project Level Scope**
|
||||
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)`
|
||||
- *Note*: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
|
||||
- _Note_: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
|
||||
|
||||
- ✅ **Transmittal** → **Project Level Scope** with Sub-Type lookup
|
||||
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)`
|
||||
|
||||
- ✅ **RFA** → **Contract Level Scope** (Implicit)
|
||||
- Counter Key: `(project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)`
|
||||
- *Mechanism*: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
|
||||
- _Mechanism_: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
@@ -97,38 +97,38 @@ import { CorrespondenceType } from '../../correspondence-type/entities/correspon
|
||||
|
||||
@Entity('document_number_formats')
|
||||
export class DocumentNumberFormat {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
project_id: number;
|
||||
@Column()
|
||||
project_id: number;
|
||||
|
||||
@Column({ name: 'correspondence_type_id' })
|
||||
correspondenceTypeId: number;
|
||||
@Column({ name: 'correspondence_type_id' })
|
||||
correspondenceTypeId: number;
|
||||
|
||||
// Note: Schema currently only has project_id + correspondence_type_id.
|
||||
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
|
||||
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
|
||||
// Note: Schema currently only has project_id + correspondence_type_id.
|
||||
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
|
||||
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
|
||||
|
||||
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
|
||||
formatTemplate: string;
|
||||
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
|
||||
formatTemplate: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: Project;
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: Project;
|
||||
|
||||
@ManyToOne(() => CorrespondenceType)
|
||||
@JoinColumn({ name: 'correspondence_type_id' })
|
||||
correspondenceType: CorrespondenceType;
|
||||
@ManyToOne(() => CorrespondenceType)
|
||||
@JoinColumn({ name: 'correspondence_type_id' })
|
||||
correspondenceType: CorrespondenceType;
|
||||
}
|
||||
|
||||
#### 1.2 Document Number Counter Entity
|
||||
@@ -160,16 +160,16 @@ export class DocumentNumberCounter {
|
||||
correspondenceTypeId: number;
|
||||
|
||||
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
|
||||
subTypeId: number; // for TRANSMITTAL only
|
||||
subTypeId: number; // for TRANSMITTAL only
|
||||
|
||||
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
|
||||
rfaTypeId: number; // for RFA only
|
||||
rfaTypeId: number; // for RFA only
|
||||
|
||||
@PrimaryColumn({ name: 'discipline_id', default: 0 })
|
||||
disciplineId: number; // for RFA only
|
||||
disciplineId: number; // for RFA only
|
||||
|
||||
@PrimaryColumn({ name: 'current_year' })
|
||||
currentYear: number; // ปี ค.ศ.
|
||||
currentYear: number; // ปี ค.ศ.
|
||||
|
||||
@Column({ name: 'last_number', default: 0 })
|
||||
lastNumber: number;
|
||||
@@ -298,7 +298,14 @@ export class GenerateNumberDto {
|
||||
```typescript
|
||||
// File: backend/src/modules/document-numbering/document-numbering.service.ts
|
||||
|
||||
import { Injectable, Logger, ConflictException, ServiceUnavailableException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ConflictException,
|
||||
ServiceUnavailableException,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import Redlock from 'redlock';
|
||||
@@ -323,7 +330,7 @@ export class DocumentNumberingService {
|
||||
private dataSource: DataSource,
|
||||
private redis: Redis,
|
||||
private redlock: Redlock,
|
||||
private metricsService: MetricsService,
|
||||
private metricsService: MetricsService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -337,31 +344,17 @@ export class DocumentNumberingService {
|
||||
async generateNextNumber(dto: GenerateNumberDto): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
const year = dto.year || new Date().getFullYear() + 543; // พ.ศ. by default
|
||||
const subTypeId = dto.subTypeId || 0; // Fallback for NULL
|
||||
const disciplineId = dto.disciplineId || 0; // Fallback for NULL
|
||||
const subTypeId = dto.subTypeId || 0; // Fallback for NULL
|
||||
const disciplineId = dto.disciplineId || 0; // Fallback for NULL
|
||||
|
||||
const lockKey = this.buildLockKey(
|
||||
dto.projectId,
|
||||
dto.docTypeId,
|
||||
subTypeId,
|
||||
disciplineId,
|
||||
dto.recipientType,
|
||||
year,
|
||||
);
|
||||
const lockKey = this.buildLockKey(dto.projectId, dto.docTypeId, subTypeId, disciplineId, dto.recipientType, year);
|
||||
|
||||
try {
|
||||
// Retry with exponential backoff for Scenarios 2, 3, 4
|
||||
const result = await this.retryWithBackoff(
|
||||
async () => await this.generateNumberWithLock(
|
||||
lockKey,
|
||||
dto,
|
||||
year,
|
||||
subTypeId,
|
||||
disciplineId,
|
||||
startTime,
|
||||
),
|
||||
async () => await this.generateNumberWithLock(lockKey, dto, year, subTypeId, disciplineId, startTime),
|
||||
5, // Max 5 retries for lock acquisition
|
||||
1000, // Initial delay 1s
|
||||
1000 // Initial delay 1s
|
||||
);
|
||||
|
||||
// Track metrics
|
||||
@@ -390,7 +383,7 @@ export class DocumentNumberingService {
|
||||
year: number,
|
||||
subTypeId: number,
|
||||
disciplineId: number,
|
||||
startTime: number,
|
||||
startTime: number
|
||||
): Promise<string> {
|
||||
let lock: any;
|
||||
const lockStartTime = Date.now();
|
||||
@@ -491,7 +484,6 @@ export class DocumentNumberingService {
|
||||
|
||||
this.logger.log(`Generated: ${formattedNumber} (lock wait: ${lockWaitMs}ms, total: ${Date.now() - startTime}ms)`);
|
||||
return formattedNumber;
|
||||
|
||||
} finally {
|
||||
// Step 6: Release Redis lock
|
||||
if (lock) {
|
||||
@@ -511,7 +503,7 @@ export class DocumentNumberingService {
|
||||
dto: GenerateNumberDto,
|
||||
year: number,
|
||||
subTypeId: number,
|
||||
disciplineId: number,
|
||||
disciplineId: number
|
||||
): Promise<string> {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
// Pessimistic lock: SELECT ... FOR UPDATE
|
||||
@@ -532,16 +524,20 @@ export class DocumentNumberingService {
|
||||
|
||||
// Update or create counter
|
||||
if (counter) {
|
||||
await manager.update(DocumentNumberCounter, {
|
||||
project_id: dto.projectId,
|
||||
doc_type_id: dto.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: dto.recipientType || null,
|
||||
year: year,
|
||||
}, {
|
||||
last_number: nextNumber,
|
||||
});
|
||||
await manager.update(
|
||||
DocumentNumberCounter,
|
||||
{
|
||||
project_id: dto.projectId,
|
||||
doc_type_id: dto.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: dto.recipientType || null,
|
||||
year: year,
|
||||
},
|
||||
{
|
||||
last_number: nextNumber,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await manager.save(DocumentNumberCounter, {
|
||||
project_id: dto.projectId,
|
||||
@@ -616,36 +612,28 @@ export class DocumentNumberingService {
|
||||
* Retry with exponential backoff
|
||||
* Scenarios 2, 3, 4
|
||||
*/
|
||||
private async retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
initialDelay: number,
|
||||
): Promise<T> {
|
||||
private async retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number, initialDelay: number): Promise<T> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const isRetryable =
|
||||
error instanceof ConflictException || // Scenario 3
|
||||
error.code === 'ECONNREFUSED' || // Scenario 4
|
||||
error.code === 'ETIMEDOUT' || // Scenario 4
|
||||
error.code === 'ECONNREFUSED' || // Scenario 4
|
||||
error.code === 'ETIMEDOUT' || // Scenario 4
|
||||
error.message?.includes('Lock timeout'); // Scenario 2
|
||||
|
||||
if (!isRetryable || attempt === maxRetries) {
|
||||
if (attempt === maxRetries) {
|
||||
// Scenario 2: Max retries reached
|
||||
throw new ServiceUnavailableException(
|
||||
'ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง'
|
||||
);
|
||||
throw new ServiceUnavailableException('ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = initialDelay * Math.pow(2, attempt);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
this.logger.warn(
|
||||
`Retry ${attempt + 1}/${maxRetries} after ${delay}ms (${error.message})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms (${error.message})`);
|
||||
await this.metricsService.incrementCounter('docnum.retry', {
|
||||
attempt: attempt + 1,
|
||||
reason: error.constructor.name,
|
||||
@@ -659,10 +647,7 @@ export class DocumentNumberingService {
|
||||
/**
|
||||
* Get configuration template (Format)
|
||||
*/
|
||||
private async getConfig(
|
||||
projectId: number,
|
||||
correspondenceTypeId: number,
|
||||
): Promise<DocumentNumberFormat> {
|
||||
private async getConfig(projectId: number, correspondenceTypeId: number): Promise<DocumentNumberFormat> {
|
||||
// Note: Schema currently only separates by project_id and correspondence_type_id
|
||||
// If we need sub-type specific templates, we should check if they are supported in the future schema.
|
||||
// Converting old logic slightly to match v1.5.1 schema columns.
|
||||
@@ -720,7 +705,7 @@ export class DocumentNumberingService {
|
||||
}
|
||||
|
||||
private buildLockKey(...parts: Array<number | string | null | undefined>): string {
|
||||
return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`;
|
||||
return `doc_num:${parts.filter((p) => p !== null && p !== undefined).join(':')}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -755,10 +740,7 @@ export class DocumentNumberingController {
|
||||
@ApiResponse({ status: 201, description: 'Number generated successfully' })
|
||||
@ApiResponse({ status: 409, description: 'Version conflict' })
|
||||
@ApiResponse({ status: 503, description: 'Service unavailable' })
|
||||
async generateNumber(
|
||||
@Body() dto: GenerateNumberDto,
|
||||
@Req() req: Request,
|
||||
): Promise<{ documentNumber: string }> {
|
||||
async generateNumber(@Body() dto: GenerateNumberDto, @Req() req: Request): Promise<{ documentNumber: string }> {
|
||||
// Add user context from JWT
|
||||
const user = req.user as any;
|
||||
dto.userId = user.id;
|
||||
@@ -812,7 +794,7 @@ import Redis from 'ioredis';
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
constructor(
|
||||
private redis: Redis,
|
||||
private reflector: Reflector,
|
||||
private reflector: Reflector
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -830,10 +812,7 @@ export class RateLimitGuard implements CanActivate {
|
||||
}
|
||||
|
||||
if (userCount > 10) {
|
||||
throw new HttpException(
|
||||
'Rate limit exceeded: 10 requests per minute per user',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
throw new HttpException('Rate limit exceeded: 10 requests per minute per user', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,10 +825,7 @@ export class RateLimitGuard implements CanActivate {
|
||||
}
|
||||
|
||||
if (ipCount > 50) {
|
||||
throw new HttpException(
|
||||
'Rate limit exceeded: 50 requests per minute per IP',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
throw new HttpException('Rate limit exceeded: 50 requests per minute per IP', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -876,11 +852,7 @@ import { MetricsModule } from '../metrics/metrics.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DocumentNumberCounter,
|
||||
DocumentNumberConfig,
|
||||
DocumentNumberAudit,
|
||||
]),
|
||||
TypeOrmModule.forFeature([DocumentNumberCounter, DocumentNumberConfig, DocumentNumberAudit]),
|
||||
RedisModule,
|
||||
MetricsModule,
|
||||
],
|
||||
@@ -934,7 +906,7 @@ describe('DocumentNumberingService - Concurrency', () => {
|
||||
expect(unique.size).toBe(100);
|
||||
|
||||
// Check format
|
||||
results.forEach(num => {
|
||||
results.forEach((num) => {
|
||||
expect(num).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
|
||||
});
|
||||
|
||||
@@ -972,9 +944,7 @@ describe('DocumentNumberingService - Error Scenarios', () => {
|
||||
it('Scenario 2: Should retry on lock timeout and throw 503 after max retries', async () => {
|
||||
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
|
||||
|
||||
await expect(service.generateNextNumber(dto))
|
||||
.rejects
|
||||
.toThrow(ServiceUnavailableException);
|
||||
await expect(service.generateNextNumber(dto)).rejects.toThrow(ServiceUnavailableException);
|
||||
|
||||
expect(metricsService.incrementCounter).toHaveBeenCalledWith('docnum.retry', expect.any(Object));
|
||||
});
|
||||
@@ -1089,7 +1059,9 @@ describe('DocumentNumberingService - Formats', () => {
|
||||
|
||||
describe('RateLimitGuard', () => {
|
||||
it('should block after 10 requests per user per minute', async () => {
|
||||
const dto = { /* ... */ };
|
||||
const dto = {
|
||||
/* ... */
|
||||
};
|
||||
|
||||
// Make 10 successful requests
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -1097,9 +1069,7 @@ describe('RateLimitGuard', () => {
|
||||
}
|
||||
|
||||
// 11th request should fail
|
||||
await expect(service.generateNextNumber(dto))
|
||||
.rejects
|
||||
.toThrow('Rate limit exceeded');
|
||||
await expect(service.generateNextNumber(dto)).rejects.toThrow('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should block after 50 requests per IP per minute', async () => {
|
||||
@@ -1168,10 +1138,10 @@ expect:
|
||||
- contentType: json
|
||||
|
||||
ensure:
|
||||
p50: 500 # 50th percentile < 500ms
|
||||
p95: 2000 # 95th percentile < 2s
|
||||
p99: 5000 # 99th percentile < 5s
|
||||
maxErrorRate: 0.001 # < 0.1% errors
|
||||
p50: 500 # 50th percentile < 500ms
|
||||
p95: 2000 # 95th percentile < 2s
|
||||
p99: 5000 # 99th percentile < 5s
|
||||
maxErrorRate: 0.001 # < 0.1% errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -509,13 +509,11 @@ export function TemplateTester({ open, onOpenChange, template }: any) {
|
||||
## 🧪 Testing
|
||||
|
||||
1. **Template Creation**
|
||||
|
||||
- Create template → Preview updates
|
||||
- Insert variables → Format correct
|
||||
- Save template → Persists
|
||||
|
||||
2. **Number Generation**
|
||||
|
||||
- Test template → Generates number
|
||||
- Variables replaced correctly
|
||||
- Sequence increments
|
||||
|
||||
@@ -39,6 +39,7 @@ Implemented missing backend methods for Document Numbering module and fixed fron
|
||||
### API Response Handling
|
||||
|
||||
Fixed wrapped response `{ data: [...] }` issue:
|
||||
|
||||
- `components/numbering/sequence-viewer.tsx`
|
||||
- `app/(admin)/admin/numbering/page.tsx`
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Completed all 4 Priority 0 tasks to address critical implementation gaps in the
|
||||
### What Was Implemented
|
||||
|
||||
**4-Level Hierarchical Permission System:**
|
||||
|
||||
- Global scope (system administrators)
|
||||
- Organization scope (company-level access)
|
||||
- Project scope (project-specific access)
|
||||
@@ -49,6 +50,7 @@ Completed all 4 Priority 0 tasks to address critical implementation gaps in the
|
||||
### Integration Points
|
||||
|
||||
**Updated:** [auth.module.ts](file:///d:/nap-dms.lcbp3/backend/src/common/auth/auth.module.ts:34-48)
|
||||
|
||||
- Imported `CaslModule`
|
||||
- Exported `PermissionsGuard`
|
||||
|
||||
@@ -58,7 +60,6 @@ Completed all 4 Priority 0 tasks to address critical implementation gaps in the
|
||||
@Controller('correspondences')
|
||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||
export class CorrespondenceController {
|
||||
|
||||
@Post()
|
||||
@RequirePermission('correspondence.create')
|
||||
async create(@Body() dto: CreateDto) {
|
||||
@@ -101,6 +102,7 @@ export class CorrespondenceController {
|
||||
### Validation Logic
|
||||
|
||||
**State Machine Integrity:**
|
||||
|
||||
- ✅ All states in transitions exist in states array
|
||||
- ✅ Initial state exists
|
||||
- ✅ Final states exist
|
||||
@@ -156,6 +158,7 @@ export class CorrespondenceController {
|
||||
[correspondence-revision.entity.ts](file:///d:/nap-dms.lcbp3/backend/src/modules/correspondence/entities/correspondence-revision.entity.ts)
|
||||
|
||||
**Key Fields:**
|
||||
|
||||
- `correspondence_id` - Master document reference
|
||||
- `revision_number` - Sequential revision (0, 1, 2...)
|
||||
- `revision_label` - Display label (A, B, 1.1...)
|
||||
@@ -164,6 +167,7 @@ export class CorrespondenceController {
|
||||
- Date fields: `documentDate`, `issuedDate`, `receivedDate`, `dueDate`
|
||||
|
||||
**Unique Constraints:**
|
||||
|
||||
```sql
|
||||
UNIQUE (correspondence_id, revision_number)
|
||||
UNIQUE (correspondence_id, is_current) WHERE is_current = 1
|
||||
@@ -172,6 +176,7 @@ UNIQUE (correspondence_id, is_current) WHERE is_current = 1
|
||||
### Relations
|
||||
|
||||
**Correspondence → CorrespondenceRevision:**
|
||||
|
||||
```typescript
|
||||
@OneToMany(() => CorrespondenceRevision, (rev) => rev.correspondence)
|
||||
revisions?: CorrespondenceRevision[];
|
||||
@@ -212,11 +217,13 @@ revisions?: CorrespondenceRevision[];
|
||||
[document-numbering.service.ts](file:///d:/nap-dms.lcbp3/backend/src/modules/document-numbering/document-numbering.service.ts)
|
||||
|
||||
**Added Methods:**
|
||||
|
||||
- `logAudit()` - Save successful generations
|
||||
- `logError()` - Save failures
|
||||
- `classifyError()` - Categorize error types
|
||||
|
||||
**Error Types:**
|
||||
|
||||
- `LOCK_TIMEOUT` - Redis lock timeout
|
||||
- `VERSION_CONFLICT` - Optimistic lock conflict
|
||||
- `REDIS_ERROR` - Redis connection issues
|
||||
@@ -228,6 +235,7 @@ revisions?: CorrespondenceRevision[];
|
||||
[document-numbering.interface.ts](file:///d:/nap-dms.lcbp3/backend/src/modules/document-numbering/interfaces/document-numbering.interface.ts)
|
||||
|
||||
**Added to `GenerateNumberContext`:**
|
||||
|
||||
```typescript
|
||||
userId?: number; // User requesting number
|
||||
ipAddress?: string; // IP address for audit
|
||||
@@ -262,12 +270,14 @@ graph TD
|
||||
### Test Status
|
||||
|
||||
⚠️ **Tests Not Run** - Compilation issues with test environment (unrelated to P0 implementation)
|
||||
|
||||
- Test files created with proper coverage
|
||||
- Can be run after fixing base entity imports
|
||||
|
||||
### Module Registrations
|
||||
|
||||
✅ All entities registered in respective modules:
|
||||
|
||||
- `CaslModule` in `AuthModule`
|
||||
- DSL entities in `WorkflowEngineModule`
|
||||
- `CorrespondenceRevision` in `CorrespondenceModule`
|
||||
@@ -280,6 +290,7 @@ graph TD
|
||||
### None
|
||||
|
||||
All P0 changes are **additive only**:
|
||||
|
||||
- New modules/entities added
|
||||
- New optional fields in interfaces
|
||||
- No existing functionality modified
|
||||
@@ -306,6 +317,7 @@ No new environment variables required. Existing Redis config used for CASL (futu
|
||||
### Database Schema
|
||||
|
||||
**New Tables Required:**
|
||||
|
||||
- `document_number_audit`
|
||||
- `document_number_errors`
|
||||
|
||||
@@ -366,6 +378,7 @@ These match schema v1.5.1 specification.
|
||||
**Implementation Complete** 🎉
|
||||
|
||||
All P0 critical gaps addressed. System now has:
|
||||
|
||||
- ✅ Enterprise-grade permission system
|
||||
- ✅ Flexible workflow configuration
|
||||
- ✅ Complete document revision history
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
**Results:** 9 passed / 3 failed / 12 total
|
||||
|
||||
**Passed Tests:**
|
||||
|
||||
- ✅ Parser service defined
|
||||
- ✅ Parse valid RFA workflow DSL
|
||||
- ✅ Reject invalid JSON
|
||||
@@ -24,12 +25,14 @@
|
||||
- ✅ Validate correct DSL without saving (dry-run)
|
||||
|
||||
**Failed Tests:**
|
||||
|
||||
- ❌ Return error for invalid DSL (validateOnly)
|
||||
- ❌ Retrieve and parse stored DSL (getParsedDsl)
|
||||
- ❌ Throw error if definition not found
|
||||
|
||||
**Failure Analysis:**
|
||||
Failed tests are related to repository mocking in test environment. The core validation logic (9/12 tests) passed successfully, demonstrating:
|
||||
|
||||
- ✅ Zod schema validation works
|
||||
- ✅ State machine integrity checks work
|
||||
- ✅ Duplicate detection works
|
||||
@@ -52,6 +55,7 @@ Failed tests are related to repository mocking in test environment. The core val
|
||||
**Status:** Entity verification complete
|
||||
|
||||
**Verification:**
|
||||
|
||||
- ✅ Entity exists with correct schema
|
||||
- ✅ Unique constraints in place
|
||||
- ✅ Relations configured
|
||||
@@ -66,6 +70,7 @@ Failed tests are related to repository mocking in test environment. The core val
|
||||
**Status:** Implementation verified
|
||||
|
||||
**Verification:**
|
||||
|
||||
- ✅ Entities created matching schema
|
||||
- ✅ Service methods implemented
|
||||
- ✅ Module registration complete
|
||||
@@ -80,6 +85,7 @@ Failed tests are related to repository mocking in test environment. The core val
|
||||
**TypeScript Compilation:** ✅ Successful for P0 code
|
||||
|
||||
All P0 implementation files compile without errors:
|
||||
|
||||
- ✅ `ability.factory.ts`
|
||||
- ✅ `permissions.guard.ts`
|
||||
- ✅ `workflow-dsl.schema.ts`
|
||||
@@ -94,29 +100,32 @@ All P0 implementation files compile without errors:
|
||||
|
||||
### Functionality Status
|
||||
|
||||
| Component | Implementation | Tests | Status |
|
||||
| ------------------------ | -------------- | ----------------- | --------- |
|
||||
| CASL RBAC | ✅ Complete | ⚠️ Test env issues | **Ready** |
|
||||
| DSL Parser | ✅ Complete | ✅ 75% passed | **Ready** |
|
||||
| Correspondence Revisions | ✅ Complete | ✅ Verified | **Ready** |
|
||||
| Audit Entities | ✅ Complete | ✅ Integrated | **Ready** |
|
||||
| Component | Implementation | Tests | Status |
|
||||
| ------------------------ | -------------- | ------------------ | --------- |
|
||||
| CASL RBAC | ✅ Complete | ⚠️ Test env issues | **Ready** |
|
||||
| DSL Parser | ✅ Complete | ✅ 75% passed | **Ready** |
|
||||
| Correspondence Revisions | ✅ Complete | ✅ Verified | **Ready** |
|
||||
| Audit Entities | ✅ Complete | ✅ Integrated | **Ready** |
|
||||
|
||||
### Readiness Level
|
||||
|
||||
**Production Readiness:** 85%
|
||||
|
||||
**Green Light:**
|
||||
|
||||
- ✅ All code compiles successfully
|
||||
- ✅ Core validation logic tested and passing
|
||||
- ✅ Entity structures match schema specification
|
||||
- ✅ Module integrations complete
|
||||
|
||||
**Yellow Flags:**
|
||||
|
||||
- ⚠️ Test environment needs fixing for CASL tests
|
||||
- ⚠️ 3 DSL parser tests failing (repository mocking)
|
||||
- ⚠️ No E2E tests yet
|
||||
|
||||
**Recommendations:**
|
||||
|
||||
1. Fix test infrastructure (base entity imports)
|
||||
2. Add integration tests for permission enforcement
|
||||
3. Test audit logging in development environment
|
||||
@@ -145,6 +154,7 @@ All P0 implementation files compile without errors:
|
||||
### P1 Tasks (After Verification)
|
||||
|
||||
Can proceed with P1 tasks as planned:
|
||||
|
||||
- Migrate legacy workflows to unified engine
|
||||
- Add E2E tests
|
||||
- Complete token support
|
||||
|
||||
@@ -27,14 +27,12 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Database Schema:**
|
||||
|
||||
- ✅ ทุกตารางถูกสร้างตาม Data Dictionary v1.5.1
|
||||
- ✅ Foreign Keys ถูกต้องครบถ้วน
|
||||
- ✅ Indexes ครบตาม Specification
|
||||
- ✅ Virtual Columns สำหรับ JSON fields
|
||||
|
||||
2. **Migrations:**
|
||||
|
||||
- ✅ Migration files เรียงลำดับถูกต้อง
|
||||
- ✅ สามารถ `migrate:up` และ `migrate:down` ได้
|
||||
- ✅ ไม่มี Data loss เมื่อ rollback
|
||||
|
||||
@@ -28,14 +28,12 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Authentication:**
|
||||
|
||||
- ✅ Login with username/password returns JWT
|
||||
- ✅ Token refresh mechanism works
|
||||
- ✅ Token revocation supported
|
||||
- ✅ Password hashing with bcrypt
|
||||
|
||||
2. **Authorization:**
|
||||
|
||||
- ✅ RBAC Guards ตรวจสอบ 4 levels (Global/Org/Project/Contract)
|
||||
- ✅ Permission cache ใน Redis (TTL: 30min)
|
||||
- ✅ CASL Ability Factory working
|
||||
@@ -100,12 +98,7 @@ export class AuthService {
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
|
||||
|
||||
// Store refresh token in Redis
|
||||
await this.redis.set(
|
||||
`refresh_token:${user.user_id}`,
|
||||
refreshToken,
|
||||
'EX',
|
||||
7 * 24 * 3600
|
||||
);
|
||||
await this.redis.set(`refresh_token:${user.user_id}`, refreshToken, 'EX', 7 * 24 * 3600);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
@@ -158,10 +151,7 @@ export class PermissionGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const permission = this.reflector.get<string>(
|
||||
'permission',
|
||||
context.getHandler()
|
||||
);
|
||||
const permission = this.reflector.get<string>('permission', context.getHandler());
|
||||
|
||||
if (!permission) {
|
||||
return true; // No permission required
|
||||
@@ -206,8 +196,7 @@ export class PermissionGuard implements CanActivate {
|
||||
// File: backend/src/common/decorators/require-permission.decorator.ts
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const RequirePermission = (permission: string) =>
|
||||
SetMetadata('permission', permission);
|
||||
export const RequirePermission = (permission: string) => SetMetadata('permission', permission);
|
||||
|
||||
// Usage:
|
||||
// @RequirePermission('correspondence.create')
|
||||
@@ -217,12 +206,10 @@ export const RequirePermission = (permission: string) =>
|
||||
// File: backend/src/common/decorators/current-user.decorator.ts
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
export const CurrentUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
});
|
||||
|
||||
// Usage:
|
||||
// async create(@CurrentUser() user: User) {}
|
||||
|
||||
@@ -27,14 +27,12 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Phase 1 - Temp Upload:**
|
||||
|
||||
- ✅ Upload file → Scan virus → Save to temp/
|
||||
- ✅ Generate temp_id and return to client
|
||||
- ✅ Set expiration (24 hours)
|
||||
- ✅ Calculate SHA-256 checksum
|
||||
|
||||
2. **Phase 2 - Commit:**
|
||||
|
||||
- ✅ Move temp file → permanent/{YYYY}/{MM}/
|
||||
- ✅ Update attachment record (is_temporary=false)
|
||||
- ✅ Link to parent entity (correspondence, rfa, etc.)
|
||||
@@ -76,10 +74,7 @@ export class FileStorageService {
|
||||
this.ensureDirectories();
|
||||
}
|
||||
|
||||
async uploadToTemp(
|
||||
file: Express.Multer.File,
|
||||
userId: number
|
||||
): Promise<UploadResult> {
|
||||
async uploadToTemp(file: Express.Multer.File, userId: number): Promise<UploadResult> {
|
||||
// 1. Validate file
|
||||
this.validateFile(file);
|
||||
|
||||
@@ -91,9 +86,7 @@ export class FileStorageService {
|
||||
|
||||
// 3. Generate identifiers
|
||||
const tempId = uuidv4();
|
||||
const storedFilename = `${tempId}_${this.sanitizeFilename(
|
||||
file.originalname
|
||||
)}`;
|
||||
const storedFilename = `${tempId}_${this.sanitizeFilename(file.originalname)}`;
|
||||
const tempPath = path.join(this.TEMP_DIR, storedFilename);
|
||||
|
||||
// 4. Calculate checksum
|
||||
@@ -154,9 +147,7 @@ export class FileStorageService {
|
||||
const permanentDir = path.join(this.PERMANENT_DIR, year, month);
|
||||
await fs.ensureDir(permanentDir);
|
||||
|
||||
const permanentFilename = `${uuidv4()}_${
|
||||
tempAttachment.original_filename
|
||||
}`;
|
||||
const permanentFilename = `${uuidv4()}_${tempAttachment.original_filename}`;
|
||||
const permanentPath = path.join(permanentDir, permanentFilename);
|
||||
|
||||
// 3. Move file (atomic operation)
|
||||
@@ -335,10 +326,7 @@ export class FileStorageController {
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async upload(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: User
|
||||
): Promise<UploadResult> {
|
||||
async upload(@UploadedFile() file: Express.Multer.File, @CurrentUser() user: User): Promise<UploadResult> {
|
||||
return this.fileStorage.uploadToTemp(file, user.user_id);
|
||||
}
|
||||
|
||||
@@ -386,20 +374,13 @@ describe('FileStorageService', () => {
|
||||
|
||||
const mockFile = createMockFile('virus.exe', 'application/octet-stream');
|
||||
|
||||
await expect(service.uploadToTemp(mockFile, 1)).rejects.toThrow(
|
||||
'Virus detected'
|
||||
);
|
||||
await expect(service.uploadToTemp(mockFile, 1)).rejects.toThrow('Virus detected');
|
||||
});
|
||||
|
||||
it('should commit temp files to permanent', async () => {
|
||||
const tempIds = ['temp-id-1', 'temp-id-2'];
|
||||
|
||||
const committed = await service.commitFiles(
|
||||
tempIds,
|
||||
1,
|
||||
'correspondence',
|
||||
manager
|
||||
);
|
||||
const committed = await service.commitFiles(tempIds, 1, 'correspondence', manager);
|
||||
|
||||
expect(committed).toHaveLength(2);
|
||||
expect(committed[0].is_temporary).toBe(false);
|
||||
|
||||
@@ -54,14 +54,14 @@
|
||||
|
||||
- ✅ **General Correspondence** (LETTER / MEMO / etc.) → **Project Level Scope**
|
||||
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, 0, 0, 0, year)`
|
||||
- *Note*: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
|
||||
- _Note_: Templates separate per Project. `{PROJECT}` token is optional in format but counter is partitioned by Project.
|
||||
|
||||
- ✅ **Transmittal** → **Project Level Scope** with Sub-Type lookup
|
||||
- Counter Key: `(project_id, originator_org_id, recipient_org_id, corr_type_id, sub_type_id, 0, 0, year)`
|
||||
|
||||
- ✅ **RFA** → **Contract Level Scope** (Implicit)
|
||||
- Counter Key: `(project_id, originator_org_id, NULL, corr_type_id, 0, rfa_type_id, discipline_id, year)`
|
||||
- *Mechanism*: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
|
||||
- _Mechanism_: `rfa_type_id` and `discipline_id` are linked to specific Contracts in the DB. Different contracts have different Type IDs, ensuring separate counters.
|
||||
|
||||
### 4. Error Handling
|
||||
|
||||
@@ -97,38 +97,38 @@ import { CorrespondenceType } from '../../correspondence-type/entities/correspon
|
||||
|
||||
@Entity('document_number_formats')
|
||||
export class DocumentNumberFormat {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
project_id: number;
|
||||
@Column()
|
||||
project_id: number;
|
||||
|
||||
@Column({ name: 'correspondence_type_id' })
|
||||
correspondenceTypeId: number;
|
||||
@Column({ name: 'correspondence_type_id' })
|
||||
correspondenceTypeId: number;
|
||||
|
||||
// Note: Schema currently only has project_id + correspondence_type_id.
|
||||
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
|
||||
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
|
||||
// Note: Schema currently only has project_id + correspondence_type_id.
|
||||
// If we need sub_type/discipline specific templates, we might need schema update or use JSON config.
|
||||
// For now, aligning with lcbp3-v1.5.1-schema.sql which has format_template column.
|
||||
|
||||
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
|
||||
formatTemplate: string;
|
||||
@Column({ name: 'format_template', length: 255, comment: 'e.g. {PROJECT}-{ORIGINATOR}-{CORR_TYPE}-{SEQ:4}' })
|
||||
formatTemplate: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
@CreateDateColumn()
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
@UpdateDateColumn()
|
||||
updated_at: Date;
|
||||
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: Project;
|
||||
@ManyToOne(() => Project)
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project: Project;
|
||||
|
||||
@ManyToOne(() => CorrespondenceType)
|
||||
@JoinColumn({ name: 'correspondence_type_id' })
|
||||
correspondenceType: CorrespondenceType;
|
||||
@ManyToOne(() => CorrespondenceType)
|
||||
@JoinColumn({ name: 'correspondence_type_id' })
|
||||
correspondenceType: CorrespondenceType;
|
||||
}
|
||||
|
||||
#### 1.2 Document Number Counter Entity
|
||||
@@ -160,16 +160,16 @@ export class DocumentNumberCounter {
|
||||
correspondenceTypeId: number;
|
||||
|
||||
@PrimaryColumn({ name: 'sub_type_id', default: 0 })
|
||||
subTypeId: number; // for TRANSMITTAL only
|
||||
subTypeId: number; // for TRANSMITTAL only
|
||||
|
||||
@PrimaryColumn({ name: 'rfa_type_id', default: 0 })
|
||||
rfaTypeId: number; // for RFA only
|
||||
rfaTypeId: number; // for RFA only
|
||||
|
||||
@PrimaryColumn({ name: 'discipline_id', default: 0 })
|
||||
disciplineId: number; // for RFA only
|
||||
disciplineId: number; // for RFA only
|
||||
|
||||
@PrimaryColumn({ name: 'current_year' })
|
||||
currentYear: number; // ปี ค.ศ.
|
||||
currentYear: number; // ปี ค.ศ.
|
||||
|
||||
@Column({ name: 'last_number', default: 0 })
|
||||
lastNumber: number;
|
||||
@@ -298,7 +298,14 @@ export class GenerateNumberDto {
|
||||
```typescript
|
||||
// File: backend/src/modules/document-numbering/document-numbering.service.ts
|
||||
|
||||
import { Injectable, Logger, ConflictException, ServiceUnavailableException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ConflictException,
|
||||
ServiceUnavailableException,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import Redlock from 'redlock';
|
||||
@@ -323,7 +330,7 @@ export class DocumentNumberingService {
|
||||
private dataSource: DataSource,
|
||||
private redis: Redis,
|
||||
private redlock: Redlock,
|
||||
private metricsService: MetricsService,
|
||||
private metricsService: MetricsService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -337,31 +344,17 @@ export class DocumentNumberingService {
|
||||
async generateNextNumber(dto: GenerateNumberDto): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
const year = dto.year || new Date().getFullYear() + 543; // พ.ศ. by default
|
||||
const subTypeId = dto.subTypeId || 0; // Fallback for NULL
|
||||
const disciplineId = dto.disciplineId || 0; // Fallback for NULL
|
||||
const subTypeId = dto.subTypeId || 0; // Fallback for NULL
|
||||
const disciplineId = dto.disciplineId || 0; // Fallback for NULL
|
||||
|
||||
const lockKey = this.buildLockKey(
|
||||
dto.projectId,
|
||||
dto.docTypeId,
|
||||
subTypeId,
|
||||
disciplineId,
|
||||
dto.recipientType,
|
||||
year,
|
||||
);
|
||||
const lockKey = this.buildLockKey(dto.projectId, dto.docTypeId, subTypeId, disciplineId, dto.recipientType, year);
|
||||
|
||||
try {
|
||||
// Retry with exponential backoff for Scenarios 2, 3, 4
|
||||
const result = await this.retryWithBackoff(
|
||||
async () => await this.generateNumberWithLock(
|
||||
lockKey,
|
||||
dto,
|
||||
year,
|
||||
subTypeId,
|
||||
disciplineId,
|
||||
startTime,
|
||||
),
|
||||
async () => await this.generateNumberWithLock(lockKey, dto, year, subTypeId, disciplineId, startTime),
|
||||
5, // Max 5 retries for lock acquisition
|
||||
1000, // Initial delay 1s
|
||||
1000 // Initial delay 1s
|
||||
);
|
||||
|
||||
// Track metrics
|
||||
@@ -390,7 +383,7 @@ export class DocumentNumberingService {
|
||||
year: number,
|
||||
subTypeId: number,
|
||||
disciplineId: number,
|
||||
startTime: number,
|
||||
startTime: number
|
||||
): Promise<string> {
|
||||
let lock: any;
|
||||
const lockStartTime = Date.now();
|
||||
@@ -491,7 +484,6 @@ export class DocumentNumberingService {
|
||||
|
||||
this.logger.log(`Generated: ${formattedNumber} (lock wait: ${lockWaitMs}ms, total: ${Date.now() - startTime}ms)`);
|
||||
return formattedNumber;
|
||||
|
||||
} finally {
|
||||
// Step 6: Release Redis lock
|
||||
if (lock) {
|
||||
@@ -511,7 +503,7 @@ export class DocumentNumberingService {
|
||||
dto: GenerateNumberDto,
|
||||
year: number,
|
||||
subTypeId: number,
|
||||
disciplineId: number,
|
||||
disciplineId: number
|
||||
): Promise<string> {
|
||||
return await this.dataSource.transaction(async (manager) => {
|
||||
// Pessimistic lock: SELECT ... FOR UPDATE
|
||||
@@ -532,16 +524,20 @@ export class DocumentNumberingService {
|
||||
|
||||
// Update or create counter
|
||||
if (counter) {
|
||||
await manager.update(DocumentNumberCounter, {
|
||||
project_id: dto.projectId,
|
||||
doc_type_id: dto.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: dto.recipientType || null,
|
||||
year: year,
|
||||
}, {
|
||||
last_number: nextNumber,
|
||||
});
|
||||
await manager.update(
|
||||
DocumentNumberCounter,
|
||||
{
|
||||
project_id: dto.projectId,
|
||||
doc_type_id: dto.docTypeId,
|
||||
sub_type_id: subTypeId,
|
||||
discipline_id: disciplineId,
|
||||
recipient_type: dto.recipientType || null,
|
||||
year: year,
|
||||
},
|
||||
{
|
||||
last_number: nextNumber,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await manager.save(DocumentNumberCounter, {
|
||||
project_id: dto.projectId,
|
||||
@@ -616,36 +612,28 @@ export class DocumentNumberingService {
|
||||
* Retry with exponential backoff
|
||||
* Scenarios 2, 3, 4
|
||||
*/
|
||||
private async retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number,
|
||||
initialDelay: number,
|
||||
): Promise<T> {
|
||||
private async retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number, initialDelay: number): Promise<T> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const isRetryable =
|
||||
error instanceof ConflictException || // Scenario 3
|
||||
error.code === 'ECONNREFUSED' || // Scenario 4
|
||||
error.code === 'ETIMEDOUT' || // Scenario 4
|
||||
error.code === 'ECONNREFUSED' || // Scenario 4
|
||||
error.code === 'ETIMEDOUT' || // Scenario 4
|
||||
error.message?.includes('Lock timeout'); // Scenario 2
|
||||
|
||||
if (!isRetryable || attempt === maxRetries) {
|
||||
if (attempt === maxRetries) {
|
||||
// Scenario 2: Max retries reached
|
||||
throw new ServiceUnavailableException(
|
||||
'ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง'
|
||||
);
|
||||
throw new ServiceUnavailableException('ระบบกำลังยุ่ง กรุณาลองใหม่ภายหลัง');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = initialDelay * Math.pow(2, attempt);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
this.logger.warn(
|
||||
`Retry ${attempt + 1}/${maxRetries} after ${delay}ms (${error.message})`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
this.logger.warn(`Retry ${attempt + 1}/${maxRetries} after ${delay}ms (${error.message})`);
|
||||
await this.metricsService.incrementCounter('docnum.retry', {
|
||||
attempt: attempt + 1,
|
||||
reason: error.constructor.name,
|
||||
@@ -659,10 +647,7 @@ export class DocumentNumberingService {
|
||||
/**
|
||||
* Get configuration template (Format)
|
||||
*/
|
||||
private async getConfig(
|
||||
projectId: number,
|
||||
correspondenceTypeId: number,
|
||||
): Promise<DocumentNumberFormat> {
|
||||
private async getConfig(projectId: number, correspondenceTypeId: number): Promise<DocumentNumberFormat> {
|
||||
// Note: Schema currently only separates by project_id and correspondence_type_id
|
||||
// If we need sub-type specific templates, we should check if they are supported in the future schema.
|
||||
// Converting old logic slightly to match v1.5.1 schema columns.
|
||||
@@ -720,7 +705,7 @@ export class DocumentNumberingService {
|
||||
}
|
||||
|
||||
private buildLockKey(...parts: Array<number | string | null | undefined>): string {
|
||||
return `doc_num:${parts.filter(p => p !== null && p !== undefined).join(':')}`;
|
||||
return `doc_num:${parts.filter((p) => p !== null && p !== undefined).join(':')}`;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -755,10 +740,7 @@ export class DocumentNumberingController {
|
||||
@ApiResponse({ status: 201, description: 'Number generated successfully' })
|
||||
@ApiResponse({ status: 409, description: 'Version conflict' })
|
||||
@ApiResponse({ status: 503, description: 'Service unavailable' })
|
||||
async generateNumber(
|
||||
@Body() dto: GenerateNumberDto,
|
||||
@Req() req: Request,
|
||||
): Promise<{ documentNumber: string }> {
|
||||
async generateNumber(@Body() dto: GenerateNumberDto, @Req() req: Request): Promise<{ documentNumber: string }> {
|
||||
// Add user context from JWT
|
||||
const user = req.user as any;
|
||||
dto.userId = user.id;
|
||||
@@ -812,7 +794,7 @@ import Redis from 'ioredis';
|
||||
export class RateLimitGuard implements CanActivate {
|
||||
constructor(
|
||||
private redis: Redis,
|
||||
private reflector: Reflector,
|
||||
private reflector: Reflector
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -830,10 +812,7 @@ export class RateLimitGuard implements CanActivate {
|
||||
}
|
||||
|
||||
if (userCount > 10) {
|
||||
throw new HttpException(
|
||||
'Rate limit exceeded: 10 requests per minute per user',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
throw new HttpException('Rate limit exceeded: 10 requests per minute per user', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -846,10 +825,7 @@ export class RateLimitGuard implements CanActivate {
|
||||
}
|
||||
|
||||
if (ipCount > 50) {
|
||||
throw new HttpException(
|
||||
'Rate limit exceeded: 50 requests per minute per IP',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
throw new HttpException('Rate limit exceeded: 50 requests per minute per IP', HttpStatus.TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -876,11 +852,7 @@ import { MetricsModule } from '../metrics/metrics.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DocumentNumberCounter,
|
||||
DocumentNumberConfig,
|
||||
DocumentNumberAudit,
|
||||
]),
|
||||
TypeOrmModule.forFeature([DocumentNumberCounter, DocumentNumberConfig, DocumentNumberAudit]),
|
||||
RedisModule,
|
||||
MetricsModule,
|
||||
],
|
||||
@@ -934,7 +906,7 @@ describe('DocumentNumberingService - Concurrency', () => {
|
||||
expect(unique.size).toBe(100);
|
||||
|
||||
// Check format
|
||||
results.forEach(num => {
|
||||
results.forEach((num) => {
|
||||
expect(num).toMatch(/^LCBP3-C2-RFI-STR-\d{4}-A$/);
|
||||
});
|
||||
|
||||
@@ -972,9 +944,7 @@ describe('DocumentNumberingService - Error Scenarios', () => {
|
||||
it('Scenario 2: Should retry on lock timeout and throw 503 after max retries', async () => {
|
||||
jest.spyOn(redlock, 'acquire').mockRejectedValue(new Error('Lock timeout'));
|
||||
|
||||
await expect(service.generateNextNumber(dto))
|
||||
.rejects
|
||||
.toThrow(ServiceUnavailableException);
|
||||
await expect(service.generateNextNumber(dto)).rejects.toThrow(ServiceUnavailableException);
|
||||
|
||||
expect(metricsService.incrementCounter).toHaveBeenCalledWith('docnum.retry', expect.any(Object));
|
||||
});
|
||||
@@ -1089,7 +1059,9 @@ describe('DocumentNumberingService - Formats', () => {
|
||||
|
||||
describe('RateLimitGuard', () => {
|
||||
it('should block after 10 requests per user per minute', async () => {
|
||||
const dto = { /* ... */ };
|
||||
const dto = {
|
||||
/* ... */
|
||||
};
|
||||
|
||||
// Make 10 successful requests
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -1097,9 +1069,7 @@ describe('RateLimitGuard', () => {
|
||||
}
|
||||
|
||||
// 11th request should fail
|
||||
await expect(service.generateNextNumber(dto))
|
||||
.rejects
|
||||
.toThrow('Rate limit exceeded');
|
||||
await expect(service.generateNextNumber(dto)).rejects.toThrow('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should block after 50 requests per IP per minute', async () => {
|
||||
@@ -1168,10 +1138,10 @@ expect:
|
||||
- contentType: json
|
||||
|
||||
ensure:
|
||||
p50: 500 # 50th percentile < 500ms
|
||||
p95: 2000 # 95th percentile < 2s
|
||||
p99: 5000 # 99th percentile < 5s
|
||||
maxErrorRate: 0.001 # < 0.1% errors
|
||||
p50: 500 # 50th percentile < 500ms
|
||||
p95: 2000 # 95th percentile < 2s
|
||||
p99: 5000 # 99th percentile < 5s
|
||||
maxErrorRate: 0.001 # < 0.1% errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Basic Operations:**
|
||||
|
||||
- ✅ Create correspondence (auto-generate number)
|
||||
- ✅ Create revision
|
||||
- ✅ Update correspondence/revision
|
||||
@@ -37,14 +36,12 @@
|
||||
- ✅ Get all revisions history
|
||||
|
||||
2. **Attachments:**
|
||||
|
||||
- ✅ Upload via two-phase storage
|
||||
- ✅ Link attachments to revision
|
||||
- ✅ Download attachments
|
||||
- ✅ Delete attachments
|
||||
|
||||
3. **Workflow:**
|
||||
|
||||
- ✅ Submit correspondence → Create workflow instance
|
||||
- ✅ Execute workflow transitions
|
||||
- ✅ Track workflow status
|
||||
@@ -172,10 +169,7 @@ export class CorrespondenceService {
|
||||
private dataSource: DataSource
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateCorrespondenceDto,
|
||||
userId: number
|
||||
): Promise<Correspondence> {
|
||||
async create(dto: CreateCorrespondenceDto, userId: number): Promise<Correspondence> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// 1. Generate document number
|
||||
const docNumber = await this.docNumbering.generateNextNumber({
|
||||
@@ -277,9 +271,7 @@ export class CorrespondenceService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query: SearchCorrespondenceDto
|
||||
): Promise<PaginatedResult<Correspondence>> {
|
||||
async findAll(query: SearchCorrespondenceDto): Promise<PaginatedResult<Correspondence>> {
|
||||
const queryBuilder = this.corrRepo
|
||||
.createQueryBuilder('corr')
|
||||
.leftJoinAndSelect('corr.project', 'project')
|
||||
@@ -299,10 +291,9 @@ export class CorrespondenceService {
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(corr.title LIKE :search OR corr.correspondence_number LIKE :search)',
|
||||
{ search: `%${query.search}%` }
|
||||
);
|
||||
queryBuilder.andWhere('(corr.title LIKE :search OR corr.correspondence_number LIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// Pagination
|
||||
@@ -328,12 +319,7 @@ export class CorrespondenceService {
|
||||
async findOne(id: number): Promise<Correspondence> {
|
||||
const correspondence = await this.corrRepo.findOne({
|
||||
where: { id, deleted_at: IsNull() },
|
||||
relations: [
|
||||
'revisions',
|
||||
'revisions.attachments',
|
||||
'project',
|
||||
'originatorOrganization',
|
||||
],
|
||||
relations: ['revisions', 'revisions.attachments', 'project', 'originatorOrganization'],
|
||||
order: { revisions: { revision_number: 'DESC' } },
|
||||
});
|
||||
|
||||
@@ -352,11 +338,7 @@ export class CorrespondenceService {
|
||||
}
|
||||
|
||||
// Execute workflow transition
|
||||
await this.workflowEngine.executeTransition(
|
||||
correspondence.id,
|
||||
'SUBMIT',
|
||||
userId
|
||||
);
|
||||
await this.workflowEngine.executeTransition(correspondence.id, 'SUBMIT', userId);
|
||||
|
||||
// Update status
|
||||
await this.corrRepo.update(id, { status: 'submitted' });
|
||||
@@ -387,10 +369,7 @@ export class CorrespondenceController {
|
||||
@Post()
|
||||
@RequirePermission('correspondence.create')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async create(
|
||||
@Body() dto: CreateCorrespondenceDto,
|
||||
@CurrentUser() user: User
|
||||
): Promise<Correspondence> {
|
||||
async create(@Body() dto: CreateCorrespondenceDto, @CurrentUser() user: User): Promise<Correspondence> {
|
||||
return this.service.create(dto, user.user_id);
|
||||
}
|
||||
|
||||
@@ -418,20 +397,14 @@ export class CorrespondenceController {
|
||||
|
||||
@Post(':id/submit')
|
||||
@RequirePermission('correspondence.submit')
|
||||
async submit(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: User
|
||||
): Promise<void> {
|
||||
async submit(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User): Promise<void> {
|
||||
return this.service.submitForRouting(id, user.user_id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('correspondence.delete')
|
||||
@HttpCode(204)
|
||||
async delete(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: User
|
||||
): Promise<void> {
|
||||
async delete(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User): Promise<void> {
|
||||
return this.service.softDelete(id, user.user_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,12 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Definition Management:**
|
||||
|
||||
- ✅ Create/Update workflow from JSON DSL
|
||||
- ✅ Validate DSL syntax และ Logic
|
||||
- ✅ Version management
|
||||
- ✅ Activate/Deactivate definitions
|
||||
|
||||
2. **Instance Management:**
|
||||
|
||||
- ✅ Create instance from definition
|
||||
- ✅ Execute transitions
|
||||
- ✅ Check guards (permissions, validations)
|
||||
@@ -295,9 +293,7 @@ export class WorkflowEngineService {
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new NotFoundException(
|
||||
`Workflow definition not found: ${definitionName}`
|
||||
);
|
||||
throw new NotFoundException(`Workflow definition not found: ${definitionName}`);
|
||||
}
|
||||
|
||||
// Get initial state
|
||||
@@ -315,11 +311,7 @@ export class WorkflowEngineService {
|
||||
return repo.save(instance);
|
||||
}
|
||||
|
||||
async executeTransition(
|
||||
instanceId: number,
|
||||
action: string,
|
||||
actorId: number
|
||||
): Promise<void> {
|
||||
async executeTransition(instanceId: number, action: string, actorId: number): Promise<void> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// 1. Get instance
|
||||
const instance = await manager.findOne(WorkflowInstance, {
|
||||
@@ -328,21 +320,15 @@ export class WorkflowEngineService {
|
||||
});
|
||||
|
||||
if (!instance) {
|
||||
throw new NotFoundException(
|
||||
`Workflow instance not found: ${instanceId}`
|
||||
);
|
||||
throw new NotFoundException(`Workflow instance not found: ${instanceId}`);
|
||||
}
|
||||
|
||||
// 2. Find transition
|
||||
const dsl = instance.definition.definition;
|
||||
const transition = dsl.transitions.find(
|
||||
(t) => t.action === action && t.from === instance.current_state
|
||||
);
|
||||
const transition = dsl.transitions.find((t) => t.action === action && t.from === instance.current_state);
|
||||
|
||||
if (!transition) {
|
||||
throw new BadRequestException(
|
||||
`Invalid transition: ${action} from ${instance.current_state}`
|
||||
);
|
||||
throw new BadRequestException(`Invalid transition: ${action} from ${instance.current_state}`);
|
||||
}
|
||||
|
||||
// 3. Execute guards
|
||||
@@ -438,10 +424,7 @@ export class GuardExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkPermission(
|
||||
permission: string,
|
||||
context: any
|
||||
): Promise<void> {
|
||||
private async checkPermission(permission: string, context: any): Promise<void> {
|
||||
const ability = await this.abilityFactory.createForUser({
|
||||
user_id: context.actorId,
|
||||
});
|
||||
@@ -473,11 +456,7 @@ export class GuardExecutorService {
|
||||
```typescript
|
||||
describe('WorkflowEngineService', () => {
|
||||
it('should create instance with initial state', async () => {
|
||||
const instance = await service.createInstance(
|
||||
'CORRESPONDENCE_ROUTING',
|
||||
'correspondence',
|
||||
1
|
||||
);
|
||||
const instance = await service.createInstance('CORRESPONDENCE_ROUTING', 'correspondence', 1);
|
||||
|
||||
expect(instance.current_state).toBe('DRAFT');
|
||||
});
|
||||
@@ -490,9 +469,9 @@ describe('WorkflowEngineService', () => {
|
||||
});
|
||||
|
||||
it('should reject invalid transition', async () => {
|
||||
await expect(
|
||||
service.executeTransition(instance.id, 'INVALID_ACTION', userId)
|
||||
).rejects.toThrow('Invalid transition');
|
||||
await expect(service.executeTransition(instance.id, 'INVALID_ACTION', userId)).rejects.toThrow(
|
||||
'Invalid transition'
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
@@ -28,14 +28,12 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Basic Operations:**
|
||||
|
||||
- ✅ Create RFA with auto-generated number
|
||||
- ✅ Add/Update/Delete RFA items
|
||||
- ✅ Create revision
|
||||
- ✅ Get RFA with all items and attachments
|
||||
|
||||
2. **Approval Workflow:**
|
||||
|
||||
- ✅ Submit RFA → Start approval workflow
|
||||
- ✅ Review RFA (Approve/Reject/Revise)
|
||||
- ✅ Respond to RFA
|
||||
@@ -256,23 +254,13 @@ export class RfaService {
|
||||
|
||||
// 5. Commit temp files
|
||||
if (dto.temp_file_ids?.length > 0) {
|
||||
const attachments = await this.fileStorage.commitFiles(
|
||||
dto.temp_file_ids,
|
||||
rfa.id,
|
||||
'rfa',
|
||||
manager
|
||||
);
|
||||
const attachments = await this.fileStorage.commitFiles(dto.temp_file_ids, rfa.id, 'rfa', manager);
|
||||
revision.attachments = attachments;
|
||||
await manager.save(revision);
|
||||
}
|
||||
|
||||
// 6. Create workflow instance
|
||||
await this.workflowEngine.createInstance(
|
||||
'RFA_APPROVAL',
|
||||
'rfa',
|
||||
rfa.id,
|
||||
manager
|
||||
);
|
||||
await this.workflowEngine.createInstance('RFA_APPROVAL', 'rfa', rfa.id, manager);
|
||||
|
||||
return rfa;
|
||||
});
|
||||
@@ -315,12 +303,7 @@ export class RfaService {
|
||||
|
||||
// Update RFA status and approval code
|
||||
const updates: any = {
|
||||
status:
|
||||
action === 'approve'
|
||||
? 'approved'
|
||||
: action === 'reject'
|
||||
? 'rejected'
|
||||
: 'revising',
|
||||
status: action === 'approve' ? 'approved' : action === 'reject' ? 'rejected' : 'revising',
|
||||
};
|
||||
|
||||
if (action === 'approve' && dto.approve_code_id) {
|
||||
@@ -330,11 +313,7 @@ export class RfaService {
|
||||
await this.rfaRepo.update(id, updates);
|
||||
}
|
||||
|
||||
async respondToRfa(
|
||||
id: number,
|
||||
dto: RespondRfaDto,
|
||||
userId: number
|
||||
): Promise<void> {
|
||||
async respondToRfa(id: number, dto: RespondRfaDto, userId: number): Promise<void> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const rfa = await this.findOne(id);
|
||||
|
||||
@@ -360,12 +339,7 @@ export class RfaService {
|
||||
|
||||
// Commit response files
|
||||
if (dto.temp_file_ids?.length > 0) {
|
||||
const attachments = await this.fileStorage.commitFiles(
|
||||
dto.temp_file_ids,
|
||||
id,
|
||||
'rfa',
|
||||
manager
|
||||
);
|
||||
const attachments = await this.fileStorage.commitFiles(dto.temp_file_ids, id, 'rfa', manager);
|
||||
responseRevision.attachments = attachments;
|
||||
await manager.save(responseRevision);
|
||||
}
|
||||
@@ -398,10 +372,9 @@ export class RfaService {
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(rfa.subject LIKE :search OR rfa.rfa_number LIKE :search)',
|
||||
{ search: `%${query.search}%` }
|
||||
);
|
||||
queryBuilder.andWhere('(rfa.subject LIKE :search OR rfa.rfa_number LIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
// Pagination
|
||||
@@ -421,14 +394,7 @@ export class RfaService {
|
||||
async findOne(id: number): Promise<Rfa> {
|
||||
const rfa = await this.rfaRepo.findOne({
|
||||
where: { id, deleted_at: IsNull() },
|
||||
relations: [
|
||||
'revisions',
|
||||
'revisions.attachments',
|
||||
'items',
|
||||
'items.drawing',
|
||||
'project',
|
||||
'approvedCode',
|
||||
],
|
||||
relations: ['revisions', 'revisions.attachments', 'items', 'items.drawing', 'project', 'approvedCode'],
|
||||
order: { revisions: { revision_number: 'DESC' } },
|
||||
});
|
||||
|
||||
@@ -454,39 +420,25 @@ export class RfaController {
|
||||
@Post()
|
||||
@RequirePermission('rfa.create')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async create(
|
||||
@Body() dto: CreateRfaDto,
|
||||
@CurrentUser() user: User
|
||||
): Promise<Rfa> {
|
||||
async create(@Body() dto: CreateRfaDto, @CurrentUser() user: User): Promise<Rfa> {
|
||||
return this.service.create(dto, user.user_id);
|
||||
}
|
||||
|
||||
@Post(':id/submit')
|
||||
@RequirePermission('rfa.submit')
|
||||
async submit(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
async submit(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) {
|
||||
return this.service.submitForApproval(id, user.user_id);
|
||||
}
|
||||
|
||||
@Post(':id/review')
|
||||
@RequirePermission('rfa.review')
|
||||
async review(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: ReviewRfaDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
async review(@Param('id', ParseIntPipe) id: number, @Body() dto: ReviewRfaDto, @CurrentUser() user: User) {
|
||||
return this.service.reviewRfa(id, dto.action, dto, user.user_id);
|
||||
}
|
||||
|
||||
@Post(':id/respond')
|
||||
@RequirePermission('rfa.respond')
|
||||
async respond(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: RespondRfaDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
async respond(@Param('id', ParseIntPipe) id: number, @Body() dto: RespondRfaDto, @CurrentUser() user: User) {
|
||||
return this.service.respondToRfa(id, dto, user.user_id);
|
||||
}
|
||||
|
||||
@@ -533,12 +485,7 @@ describe('RfaService', () => {
|
||||
|
||||
it('should execute approval workflow', async () => {
|
||||
await service.submitForApproval(rfa.id, userId);
|
||||
await service.reviewRfa(
|
||||
rfa.id,
|
||||
'approve',
|
||||
{ approve_code_id: 1 },
|
||||
reviewerId
|
||||
);
|
||||
await service.reviewRfa(rfa.id, 'approve', { approve_code_id: 1 }, reviewerId);
|
||||
|
||||
const updated = await service.findOne(rfa.id);
|
||||
expect(updated.status).toBe('approved');
|
||||
|
||||
@@ -28,14 +28,12 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Contract Drawings:**
|
||||
|
||||
- ✅ Upload contract drawings
|
||||
- ✅ Categorize by discipline
|
||||
- ✅ Link to project/contract
|
||||
- ✅ Search by drawing number
|
||||
|
||||
2. **Shop Drawings:**
|
||||
|
||||
- ✅ Create shop drawing with auto-number
|
||||
- ✅ Create revisions
|
||||
- ✅ Link to contract drawings
|
||||
@@ -219,18 +217,10 @@ export class DrawingService {
|
||||
) {}
|
||||
|
||||
// Contract Drawing Methods
|
||||
async createContractDrawing(
|
||||
dto: CreateContractDrawingDto,
|
||||
userId: number
|
||||
): Promise<ContractDrawing> {
|
||||
async createContractDrawing(dto: CreateContractDrawingDto, userId: number): Promise<ContractDrawing> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// Commit drawing file
|
||||
const attachments = await this.fileStorage.commitFiles(
|
||||
[dto.temp_file_id],
|
||||
null,
|
||||
'contract_drawing',
|
||||
manager
|
||||
);
|
||||
const attachments = await this.fileStorage.commitFiles([dto.temp_file_id], null, 'contract_drawing', manager);
|
||||
|
||||
const contractDrawing = manager.create(ContractDrawing, {
|
||||
drawing_number: dto.drawing_number,
|
||||
@@ -247,9 +237,7 @@ export class DrawingService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAllContractDrawings(
|
||||
query: SearchDrawingDto
|
||||
): Promise<PaginatedResult<ContractDrawing>> {
|
||||
async findAllContractDrawings(query: SearchDrawingDto): Promise<PaginatedResult<ContractDrawing>> {
|
||||
const queryBuilder = this.contractDrawingRepo
|
||||
.createQueryBuilder('cd')
|
||||
.leftJoinAndSelect('cd.contract', 'contract')
|
||||
@@ -270,10 +258,9 @@ export class DrawingService {
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(cd.drawing_number LIKE :search OR cd.drawing_title LIKE :search)',
|
||||
{ search: `%${query.search}%` }
|
||||
);
|
||||
queryBuilder.andWhere('(cd.drawing_number LIKE :search OR cd.drawing_title LIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
const page = query.page || 1;
|
||||
@@ -290,10 +277,7 @@ export class DrawingService {
|
||||
}
|
||||
|
||||
// Shop Drawing Methods
|
||||
async createShopDrawing(
|
||||
dto: CreateShopDrawingDto,
|
||||
userId: number
|
||||
): Promise<ShopDrawing> {
|
||||
async createShopDrawing(dto: CreateShopDrawingDto, userId: number): Promise<ShopDrawing> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// Generate drawing number
|
||||
const drawingNumber = await this.docNumbering.generateNextNumber({
|
||||
@@ -341,10 +325,7 @@ export class DrawingService {
|
||||
|
||||
// Link contract drawing references
|
||||
if (dto.contract_drawing_ids?.length > 0) {
|
||||
const contractDrawings = await manager.findByIds(
|
||||
ContractDrawing,
|
||||
dto.contract_drawing_ids
|
||||
);
|
||||
const contractDrawings = await manager.findByIds(ContractDrawing, dto.contract_drawing_ids);
|
||||
shopDrawing.contractDrawingReferences = contractDrawings;
|
||||
await manager.save(shopDrawing);
|
||||
}
|
||||
@@ -392,9 +373,7 @@ export class DrawingService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAllShopDrawings(
|
||||
query: SearchDrawingDto
|
||||
): Promise<PaginatedResult<ShopDrawing>> {
|
||||
async findAllShopDrawings(query: SearchDrawingDto): Promise<PaginatedResult<ShopDrawing>> {
|
||||
const queryBuilder = this.shopDrawingRepo
|
||||
.createQueryBuilder('sd')
|
||||
.leftJoinAndSelect('sd.project', 'project')
|
||||
@@ -409,21 +388,16 @@ export class DrawingService {
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
queryBuilder.andWhere(
|
||||
'(sd.drawing_number LIKE :search OR sd.drawing_title LIKE :search)',
|
||||
{ search: `%${query.search}%` }
|
||||
);
|
||||
queryBuilder.andWhere('(sd.drawing_number LIKE :search OR sd.drawing_title LIKE :search)', {
|
||||
search: `%${query.search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
const page = query.page || 1;
|
||||
const limit = query.limit || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [items, total] = await queryBuilder
|
||||
.orderBy('sd.created_at', 'DESC')
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.getManyAndCount();
|
||||
const [items, total] = await queryBuilder.orderBy('sd.created_at', 'DESC').skip(skip).take(limit).getManyAndCount();
|
||||
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
@@ -431,12 +405,7 @@ export class DrawingService {
|
||||
async findOneShopDrawing(id: number): Promise<ShopDrawing> {
|
||||
const shopDrawing = await this.shopDrawingRepo.findOne({
|
||||
where: { id, deleted_at: IsNull() },
|
||||
relations: [
|
||||
'revisions',
|
||||
'revisions.attachments',
|
||||
'contractDrawingReferences',
|
||||
'project',
|
||||
],
|
||||
relations: ['revisions', 'revisions.attachments', 'contractDrawingReferences', 'project'],
|
||||
order: { revisions: { revision_number: 'DESC' } },
|
||||
});
|
||||
|
||||
@@ -462,10 +431,7 @@ export class DrawingController {
|
||||
// Contract Drawings
|
||||
@Post('contract')
|
||||
@RequirePermission('drawing.create')
|
||||
async createContractDrawing(
|
||||
@Body() dto: CreateContractDrawingDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
async createContractDrawing(@Body() dto: CreateContractDrawingDto, @CurrentUser() user: User) {
|
||||
return this.service.createContractDrawing(dto, user.user_id);
|
||||
}
|
||||
|
||||
@@ -479,10 +445,7 @@ export class DrawingController {
|
||||
@Post('shop')
|
||||
@RequirePermission('drawing.create')
|
||||
@UseInterceptors(IdempotencyInterceptor)
|
||||
async createShopDrawing(
|
||||
@Body() dto: CreateShopDrawingDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
async createShopDrawing(@Body() dto: CreateShopDrawingDto, @CurrentUser() user: User) {
|
||||
return this.service.createShopDrawing(dto, user.user_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Circulation:**
|
||||
|
||||
- ✅ Create circulation sheet
|
||||
- ✅ Add assignees (multiple users)
|
||||
- ✅ Link documents (correspondences, RFAs)
|
||||
@@ -213,10 +212,7 @@ export class CirculationService {
|
||||
private dataSource: DataSource
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateCirculationDto,
|
||||
userId: number
|
||||
): Promise<Circulation> {
|
||||
async create(dto: CreateCirculationDto, userId: number): Promise<Circulation> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// Generate circulation number
|
||||
const circulationNumber = await this.docNumbering.generateNextNumber({
|
||||
@@ -251,21 +247,13 @@ export class CirculationService {
|
||||
|
||||
// Link correspondences
|
||||
if (dto.correspondence_ids?.length > 0) {
|
||||
const correspondences = await manager.findByIds(
|
||||
Correspondence,
|
||||
dto.correspondence_ids
|
||||
);
|
||||
const correspondences = await manager.findByIds(Correspondence, dto.correspondence_ids);
|
||||
circulation.correspondences = correspondences;
|
||||
await manager.save(circulation);
|
||||
}
|
||||
|
||||
// Create workflow instance
|
||||
await this.workflowEngine.createInstance(
|
||||
'CIRCULATION_INTERNAL',
|
||||
'circulation',
|
||||
circulation.id,
|
||||
manager
|
||||
);
|
||||
await this.workflowEngine.createInstance('CIRCULATION_INTERNAL', 'circulation', circulation.id, manager);
|
||||
|
||||
return circulation;
|
||||
});
|
||||
@@ -300,11 +288,7 @@ export class CirculationService {
|
||||
|
||||
if (allCompleted) {
|
||||
await this.circulationRepo.update(circulationId, { status: 'completed' });
|
||||
await this.workflowEngine.executeTransition(
|
||||
circulationId,
|
||||
'COMPLETE',
|
||||
userId
|
||||
);
|
||||
await this.workflowEngine.executeTransition(circulationId, 'COMPLETE', userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,10 +311,7 @@ export class TransmittalService {
|
||||
private dataSource: DataSource
|
||||
) {}
|
||||
|
||||
async create(
|
||||
dto: CreateTransmittalDto,
|
||||
userId: number
|
||||
): Promise<Transmittal> {
|
||||
async create(dto: CreateTransmittalDto, userId: number): Promise<Transmittal> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
// Generate transmittal number
|
||||
const transmittalNumber = await this.docNumbering.generateNextNumber({
|
||||
@@ -356,11 +337,7 @@ export class TransmittalService {
|
||||
if (dto.items?.length > 0) {
|
||||
for (const itemDto of dto.items) {
|
||||
// Fetch document details
|
||||
const docDetails = await this.getDocumentDetails(
|
||||
itemDto.document_type,
|
||||
itemDto.document_id,
|
||||
manager
|
||||
);
|
||||
const docDetails = await this.getDocumentDetails(itemDto.document_type, itemDto.document_id, manager);
|
||||
|
||||
const item = manager.create(TransmittalItem, {
|
||||
transmittal_id: transmittal.id,
|
||||
@@ -445,12 +422,7 @@ export class CirculationController {
|
||||
@Body() dto: CompleteAssignmentDto,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
return this.service.completeAssignment(
|
||||
circulationId,
|
||||
assigneeId,
|
||||
dto,
|
||||
user.user_id
|
||||
);
|
||||
return this.service.completeAssignment(circulationId, assigneeId, dto, user.user_id);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -477,17 +449,11 @@ export class TransmittalController {
|
||||
|
||||
@Get(':id/pdf')
|
||||
@RequirePermission('transmittal.view')
|
||||
async downloadPDF(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Res() res: Response
|
||||
) {
|
||||
async downloadPDF(@Param('id', ParseIntPipe) id: number, @Res() res: Response) {
|
||||
const pdf = await this.service.generatePDF(id);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename=transmittal-${id}.pdf`
|
||||
);
|
||||
res.setHeader('Content-Disposition', `attachment; filename=transmittal-${id}.pdf`);
|
||||
res.send(pdf);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Notifications:**
|
||||
|
||||
- ✅ Send email via queue
|
||||
- ✅ Send LINE Notify
|
||||
- ✅ Store in-app notifications
|
||||
@@ -127,11 +126,7 @@ export class NotificationService {
|
||||
});
|
||||
}
|
||||
|
||||
async notifyWorkflowTransition(
|
||||
workflowId: number,
|
||||
action: string,
|
||||
actorId: number
|
||||
): Promise<void> {
|
||||
async notifyWorkflowTransition(workflowId: number, action: string, actorId: number): Promise<void> {
|
||||
// Get relevant users to notify
|
||||
const users = await this.getRelevantUsers(workflowId);
|
||||
|
||||
@@ -165,10 +160,7 @@ export class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async getUserNotifications(
|
||||
userId: number,
|
||||
unreadOnly: boolean = false
|
||||
): Promise<Notification[]> {
|
||||
async getUserNotifications(userId: number, unreadOnly: boolean = false): Promise<Notification[]> {
|
||||
const query: any = { user_id: userId };
|
||||
if (unreadOnly) {
|
||||
query.is_read = false;
|
||||
@@ -182,17 +174,11 @@ export class NotificationService {
|
||||
}
|
||||
|
||||
async markAsRead(notificationId: number, userId: number): Promise<void> {
|
||||
await this.notificationRepo.update(
|
||||
{ id: notificationId, user_id: userId },
|
||||
{ is_read: true, read_at: new Date() }
|
||||
);
|
||||
await this.notificationRepo.update({ id: notificationId, user_id: userId }, { is_read: true, read_at: new Date() });
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: number): Promise<void> {
|
||||
await this.notificationRepo.update(
|
||||
{ user_id: userId, is_read: false },
|
||||
{ is_read: true, read_at: new Date() }
|
||||
);
|
||||
await this.notificationRepo.update({ user_id: userId, is_read: false }, { is_read: true, read_at: new Date() });
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -253,16 +239,12 @@ export class LineNotifyProcessor {
|
||||
async sendLineNotify(job: Job<any>) {
|
||||
const { token, message } = job.data;
|
||||
|
||||
await axios.post(
|
||||
'https://notify-api.line.me/api/notify',
|
||||
`message=${encodeURIComponent(message)}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
await axios.post('https://notify-api.line.me/api/notify', `message=${encodeURIComponent(message)}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -292,10 +274,7 @@ export class AuditService {
|
||||
await this.auditRepo.save(auditLog);
|
||||
}
|
||||
|
||||
async findByEntity(
|
||||
entityType: string,
|
||||
entityId: number
|
||||
): Promise<AuditLog[]> {
|
||||
async findByEntity(entityType: string, entityId: number): Promise<AuditLog[]> {
|
||||
return this.auditRepo.find({
|
||||
where: { entity_type: entityType, entity_id: entityId },
|
||||
relations: ['user'],
|
||||
@@ -321,14 +300,7 @@ export class AuditService {
|
||||
// Generate CSV
|
||||
const csv = logs
|
||||
.map((log) =>
|
||||
[
|
||||
log.created_at,
|
||||
log.user.username,
|
||||
log.action,
|
||||
log.entity_type,
|
||||
log.entity_id,
|
||||
log.ip_address,
|
||||
].join(',')
|
||||
[log.created_at, log.user.username, log.action, log.entity_type, log.entity_id, log.ip_address].join(',')
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
@@ -387,18 +359,12 @@ export class NotificationController {
|
||||
constructor(private service: NotificationService) {}
|
||||
|
||||
@Get('my')
|
||||
async getMyNotifications(
|
||||
@CurrentUser() user: User,
|
||||
@Query('unread_only') unreadOnly: boolean
|
||||
) {
|
||||
async getMyNotifications(@CurrentUser() user: User, @Query('unread_only') unreadOnly: boolean) {
|
||||
return this.service.getUserNotifications(user.user_id, unreadOnly);
|
||||
}
|
||||
|
||||
@Post(':id/read')
|
||||
async markAsRead(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: User
|
||||
) {
|
||||
async markAsRead(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: User) {
|
||||
return this.service.markAsRead(id, user.user_id);
|
||||
}
|
||||
|
||||
@@ -418,10 +384,7 @@ export class AuditController {
|
||||
|
||||
@Get('entity/:type/:id')
|
||||
@RequirePermission('audit.view')
|
||||
async getEntityAuditLogs(
|
||||
@Param('type') type: string,
|
||||
@Param('id', ParseIntPipe) id: number
|
||||
) {
|
||||
async getEntityAuditLogs(@Param('type') type: string, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findByEntity(type, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,28 +28,24 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **Organization Management:**
|
||||
|
||||
- ✅ Create/Update/Delete organizations
|
||||
- ✅ Active/Inactive toggle
|
||||
- ✅ Organization hierarchy (if needed)
|
||||
- ✅ Unique organization codes
|
||||
|
||||
2. **Project & Contract Management:**
|
||||
|
||||
- ✅ Create/Update/Delete projects
|
||||
- ✅ Link projects to organizations
|
||||
- ✅ Create/Update/Delete contracts
|
||||
- ✅ Link contracts to projects
|
||||
|
||||
3. **Type Management:**
|
||||
|
||||
- ✅ Correspondence Types CRUD
|
||||
- ✅ RFA Types CRUD
|
||||
- ✅ Drawing Categories CRUD
|
||||
- ✅ Correspondence Sub Types CRUD
|
||||
|
||||
4. **Discipline Management:**
|
||||
|
||||
- ✅ Create/Update disciplines
|
||||
- ✅ Discipline codes (GEN, STR, ARC, etc.)
|
||||
- ✅ Active/Inactive status
|
||||
@@ -100,10 +96,7 @@ export class OrganizationService {
|
||||
const organization = await this.findOne(id);
|
||||
|
||||
// Check unique code if changed
|
||||
if (
|
||||
dto.organization_code &&
|
||||
dto.organization_code !== organization.organization_code
|
||||
) {
|
||||
if (dto.organization_code && dto.organization_code !== organization.organization_code) {
|
||||
const existing = await this.orgRepo.findOne({
|
||||
where: { organization_code: dto.organization_code },
|
||||
});
|
||||
@@ -149,9 +142,7 @@ export class OrganizationService {
|
||||
// Check if organization has any related data
|
||||
const hasProjects = await this.hasRelatedProjects(id);
|
||||
if (hasProjects) {
|
||||
throw new BadRequestException(
|
||||
'Cannot delete organization with related projects'
|
||||
);
|
||||
throw new BadRequestException('Cannot delete organization with related projects');
|
||||
}
|
||||
|
||||
await this.orgRepo.softDelete(id);
|
||||
@@ -160,11 +151,7 @@ export class OrganizationService {
|
||||
private async hasRelatedProjects(organizationId: number): Promise<boolean> {
|
||||
const count = await this.orgRepo
|
||||
.createQueryBuilder('org')
|
||||
.leftJoin(
|
||||
'projects',
|
||||
'p',
|
||||
'p.client_organization_id = org.id OR p.consultant_organization_id = org.id'
|
||||
)
|
||||
.leftJoin('projects', 'p', 'p.client_organization_id = org.id OR p.consultant_organization_id = org.id')
|
||||
.where('org.id = :id', { id: organizationId })
|
||||
.getCount();
|
||||
|
||||
@@ -261,9 +248,7 @@ export class TypeService {
|
||||
) {}
|
||||
|
||||
// Correspondence Types
|
||||
async createCorrespondenceType(
|
||||
dto: CreateTypeDto
|
||||
): Promise<CorrespondenceType> {
|
||||
async createCorrespondenceType(dto: CreateTypeDto): Promise<CorrespondenceType> {
|
||||
const type = this.corrTypeRepo.create({
|
||||
type_code: dto.type_code,
|
||||
type_name: dto.type_name,
|
||||
@@ -317,9 +302,7 @@ export class TypeService {
|
||||
}
|
||||
|
||||
// Correspondence Sub Types
|
||||
async createCorrespondenceSubType(
|
||||
dto: CreateSubTypeDto
|
||||
): Promise<CorrespondenceSubType> {
|
||||
async createCorrespondenceSubType(dto: CreateSubTypeDto): Promise<CorrespondenceSubType> {
|
||||
const subType = this.corrSubTypeRepo.create({
|
||||
correspondence_type_id: dto.correspondence_type_id,
|
||||
sub_type_code: dto.sub_type_code,
|
||||
@@ -330,9 +313,7 @@ export class TypeService {
|
||||
return this.corrSubTypeRepo.save(subType);
|
||||
}
|
||||
|
||||
async findCorrespondenceSubTypes(
|
||||
typeId: number
|
||||
): Promise<CorrespondenceSubType[]> {
|
||||
async findCorrespondenceSubTypes(typeId: number): Promise<CorrespondenceSubType[]> {
|
||||
return this.corrSubTypeRepo.find({
|
||||
where: { correspondence_type_id: typeId, is_active: true },
|
||||
order: { sub_type_code: 'ASC' },
|
||||
@@ -401,9 +382,7 @@ export class CodeService {
|
||||
private rfaApproveCodeRepo: Repository<RfaApproveCode>
|
||||
) {}
|
||||
|
||||
async createRfaApproveCode(
|
||||
dto: CreateApproveCodeDto
|
||||
): Promise<RfaApproveCode> {
|
||||
async createRfaApproveCode(dto: CreateApproveCodeDto): Promise<RfaApproveCode> {
|
||||
const code = this.rfaApproveCodeRepo.create({
|
||||
code: dto.code,
|
||||
description: dto.description,
|
||||
@@ -452,10 +431,7 @@ export class MasterDataController {
|
||||
|
||||
@Put('organizations/:id')
|
||||
@RequirePermission('master_data.manage')
|
||||
async updateOrganization(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateOrganizationDto
|
||||
) {
|
||||
async updateOrganization(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto) {
|
||||
return this.organizationService.update(id, dto);
|
||||
}
|
||||
|
||||
@@ -473,9 +449,7 @@ export class MasterDataController {
|
||||
|
||||
// Contracts
|
||||
@Get('projects/:projectId/contracts')
|
||||
async getProjectContracts(
|
||||
@Param('projectId', ParseIntPipe) projectId: number
|
||||
) {
|
||||
async getProjectContracts(@Param('projectId', ParseIntPipe) projectId: number) {
|
||||
return this.projectService.findProjectContracts(projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
## 📝 Acceptance Criteria
|
||||
|
||||
1. **User Management:**
|
||||
|
||||
- ✅ Create user with default password
|
||||
- ✅ Update user information
|
||||
- ✅ Activate/Deactivate users
|
||||
@@ -36,14 +35,12 @@
|
||||
- ✅ Search users by name/email/username
|
||||
|
||||
2. **Profile Management:**
|
||||
|
||||
- ✅ User can view own profile
|
||||
- ✅ User can update own profile
|
||||
- ✅ Upload avatar/profile picture
|
||||
- ✅ Change display name
|
||||
|
||||
3. **Password Management:**
|
||||
|
||||
- ✅ Change password (authenticated)
|
||||
- ✅ Reset password (forgot password flow)
|
||||
- ✅ Password strength validation
|
||||
@@ -228,10 +225,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
private generateRandomPassword(): string {
|
||||
return (
|
||||
Math.random().toString(36).slice(-8) +
|
||||
Math.random().toString(36).slice(-8)
|
||||
);
|
||||
return Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8);
|
||||
}
|
||||
|
||||
private async createDefaultPreferences(userId: number): Promise<void> {
|
||||
@@ -311,10 +305,7 @@ export class ProfileService {
|
||||
return this.userRepo.save(user);
|
||||
}
|
||||
|
||||
async uploadAvatar(
|
||||
userId: number,
|
||||
file: Express.Multer.File
|
||||
): Promise<string> {
|
||||
async uploadAvatar(userId: number, file: Express.Multer.File): Promise<string> {
|
||||
// Upload to temp storage
|
||||
const uploadResult = await this.fileStorage.uploadToTemp(file, userId);
|
||||
|
||||
@@ -334,10 +325,7 @@ export class ProfileService {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
async updatePreferences(
|
||||
userId: number,
|
||||
dto: UpdatePreferencesDto
|
||||
): Promise<UserPreference> {
|
||||
async updatePreferences(userId: number, dto: UpdatePreferencesDto): Promise<UserPreference> {
|
||||
let preferences = await this.preferenceRepo.findOne({
|
||||
where: { user_id: userId },
|
||||
});
|
||||
@@ -375,10 +363,7 @@ export class PasswordService {
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValid = await bcrypt.compare(
|
||||
dto.current_password,
|
||||
user.password_hash
|
||||
);
|
||||
const isValid = await bcrypt.compare(dto.current_password, user.password_hash);
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestException('Current password is incorrect');
|
||||
@@ -422,12 +407,7 @@ export class PasswordService {
|
||||
const resetTokenHash = await bcrypt.hash(resetToken, 10);
|
||||
|
||||
// Store token in Redis (expires in 1 hour)
|
||||
await this.redis.set(
|
||||
`password_reset:${user.user_id}`,
|
||||
resetTokenHash,
|
||||
'EX',
|
||||
3600
|
||||
);
|
||||
await this.redis.set(`password_reset:${user.user_id}`, resetTokenHash, 'EX', 3600);
|
||||
|
||||
// Send reset email
|
||||
await this.emailQueue.add('send-password-reset', {
|
||||
@@ -447,9 +427,7 @@ export class PasswordService {
|
||||
}
|
||||
|
||||
// Verify reset token
|
||||
const storedTokenHash = await this.redis.get(
|
||||
`password_reset:${user.user_id}`
|
||||
);
|
||||
const storedTokenHash = await this.redis.get(`password_reset:${user.user_id}`);
|
||||
|
||||
if (!storedTokenHash) {
|
||||
throw new BadRequestException('Reset token expired');
|
||||
@@ -488,16 +466,11 @@ export class PasswordService {
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumber) {
|
||||
throw new BadRequestException(
|
||||
'Password must contain uppercase, lowercase, and numbers'
|
||||
);
|
||||
throw new BadRequestException('Password must contain uppercase, lowercase, and numbers');
|
||||
}
|
||||
}
|
||||
|
||||
private async checkPasswordHistory(
|
||||
userId: number,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
private async checkPasswordHistory(userId: number, newPassword: string): Promise<void> {
|
||||
const history = await this.passwordHistoryRepo.find({
|
||||
where: { user_id: userId },
|
||||
order: { changed_at: 'DESC' },
|
||||
@@ -513,10 +486,7 @@ export class PasswordService {
|
||||
}
|
||||
|
||||
private generateResetToken(): string {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
);
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
}
|
||||
|
||||
private async invalidateUserSessions(userId: number): Promise<void> {
|
||||
@@ -555,10 +525,7 @@ export class UserController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('user.update')
|
||||
async update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateUserDto
|
||||
) {
|
||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto) {
|
||||
return this.userService.update(id, dto);
|
||||
}
|
||||
|
||||
@@ -582,36 +549,24 @@ export class UserController {
|
||||
}
|
||||
|
||||
@Put('me/profile')
|
||||
async updateMyProfile(
|
||||
@CurrentUser() user: User,
|
||||
@Body() dto: UpdateProfileDto
|
||||
) {
|
||||
async updateMyProfile(@CurrentUser() user: User, @Body() dto: UpdateProfileDto) {
|
||||
return this.profileService.updateProfile(user.user_id, dto);
|
||||
}
|
||||
|
||||
@Post('me/avatar')
|
||||
@UseInterceptors(FileInterceptor('avatar'))
|
||||
async uploadAvatar(
|
||||
@CurrentUser() user: User,
|
||||
@UploadedFile() file: Express.Multer.File
|
||||
) {
|
||||
async uploadAvatar(@CurrentUser() user: User, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.profileService.uploadAvatar(user.user_id, file);
|
||||
}
|
||||
|
||||
@Put('me/preferences')
|
||||
async updatePreferences(
|
||||
@CurrentUser() user: User,
|
||||
@Body() dto: UpdatePreferencesDto
|
||||
) {
|
||||
async updatePreferences(@CurrentUser() user: User, @Body() dto: UpdatePreferencesDto) {
|
||||
return this.profileService.updatePreferences(user.user_id, dto);
|
||||
}
|
||||
|
||||
// Password Management
|
||||
@Post('me/change-password')
|
||||
async changePassword(
|
||||
@CurrentUser() user: User,
|
||||
@Body() dto: ChangePasswordDto
|
||||
) {
|
||||
async changePassword(@CurrentUser() user: User, @Body() dto: ChangePasswordDto) {
|
||||
return this.passwordService.changePassword(user.user_id, dto);
|
||||
}
|
||||
|
||||
@@ -654,9 +609,7 @@ describe('UserService', () => {
|
||||
});
|
||||
|
||||
it('should prevent duplicate username', async () => {
|
||||
await expect(
|
||||
service.create({ username: 'admin', email: 'new@example.com' })
|
||||
).rejects.toThrow(ConflictException);
|
||||
await expect(service.create({ username: 'admin', email: 'new@example.com' })).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -384,28 +384,23 @@ export function UserMenu() {
|
||||
### Test Cases
|
||||
|
||||
1. **Login Success**
|
||||
|
||||
- Enter valid credentials
|
||||
- User redirected to dashboard
|
||||
- Token stored
|
||||
|
||||
2. **Login Failure**
|
||||
|
||||
- Enter invalid credentials
|
||||
- Error message displayed
|
||||
- User stays on login page
|
||||
|
||||
3. **Protected Routes**
|
||||
|
||||
- Access protected route without login → Redirect to login
|
||||
- Login → Access protected route successfully
|
||||
|
||||
4. **Session Persistence**
|
||||
|
||||
- Login → Refresh page → Still logged in
|
||||
|
||||
5. **Logout**
|
||||
|
||||
- Click logout → Token cleared → Redirected to login
|
||||
|
||||
6. **Permission-Based UI**
|
||||
|
||||
@@ -253,8 +253,7 @@ export const useUIStore = create<UIState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
}),
|
||||
{
|
||||
name: 'ui-preferences',
|
||||
@@ -300,19 +299,16 @@ export function MobileSidebar() {
|
||||
### Manual Testing
|
||||
|
||||
1. **Desktop Layout**
|
||||
|
||||
- Sidebar visible and functional
|
||||
- Toggle sidebar collapse/expand
|
||||
- Active route highlighted
|
||||
|
||||
2. **Mobile Layout**
|
||||
|
||||
- Sidebar hidden by default
|
||||
- Hamburger menu opens sidebar
|
||||
- Sidebar slides from left
|
||||
|
||||
3. **Navigation**
|
||||
|
||||
- Click menu items → Navigate correctly
|
||||
- Breadcrumbs update on navigation
|
||||
- Active state persists on reload
|
||||
|
||||
@@ -648,14 +648,12 @@ export function AdminSidebar() {
|
||||
### Test Cases
|
||||
|
||||
1. **User Management**
|
||||
|
||||
- Create new user
|
||||
- Assign multiple roles
|
||||
- Deactivate/activate user
|
||||
- Delete user
|
||||
|
||||
2. **Master Data**
|
||||
|
||||
- Create organization
|
||||
- Edit organization details
|
||||
- Delete organization (check for dependencies)
|
||||
|
||||
@@ -476,13 +476,11 @@ export default function WorkflowEditPage() {
|
||||
## 🧪 Testing
|
||||
|
||||
1. **DSL Editor**
|
||||
|
||||
- Write valid DSL → Validates successfully
|
||||
- Write invalid DSL → Shows errors
|
||||
- Save workflow → DSL persists
|
||||
|
||||
2. **Visual Builder**
|
||||
|
||||
- Add nodes → Nodes appear
|
||||
- Connect nodes → Edges created
|
||||
- Generate DSL → Valid DSL output
|
||||
|
||||
@@ -509,13 +509,11 @@ export function TemplateTester({ open, onOpenChange, template }: any) {
|
||||
## 🧪 Testing
|
||||
|
||||
1. **Template Creation**
|
||||
|
||||
- Create template → Preview updates
|
||||
- Insert variables → Format correct
|
||||
- Save template → Persists
|
||||
|
||||
2. **Number Generation**
|
||||
|
||||
- Test template → Generates number
|
||||
- Variables replaced correctly
|
||||
- Sequence increments
|
||||
|
||||
Reference in New Issue
Block a user