From 5d90e58bf9e13f326a3dfe0d75657b0c25bf507c Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 9 Dec 2025 20:44:54 +0530 Subject: [PATCH] dealer claim tab added some information mapped on overview tab and also fist approve can take action --- docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md | 134 ++ docs/COST_BREAKUP_TABLE_ARCHITECTURE.md | 149 ++ docs/DEALER_USER_ARCHITECTURE.md | 134 ++ docs/DYNAMIC_TEMPLATE_SYSTEM.md | 1197 +++++++++++++++++ docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md | 583 ++++++++ docs/IMPLEMENTATION_PROGRESS.md | 78 ++ docs/IMPLEMENTATION_SUMMARY.md | 159 +++ docs/OKTA_USERS_API_INTEGRATION.md | 167 +++ package.json | 3 +- src/config/sso.ts | 1 + src/controllers/dealer.controller.ts | 86 ++ src/controllers/dealerClaim.controller.ts | 418 ++++++ src/controllers/document.controller.ts | 60 +- src/controllers/template.controller.ts | 192 +++ .../20251210-add-template-id-foreign-key.ts | 54 + .../20251210-add-workflow-type-support.ts | 116 ++ .../20251210-create-dealer-claim-tables.ts | 282 ++++ ...251210-create-proposal-cost-items-table.ts | 194 +++ .../20251210-enhance-workflow-templates.ts | 174 +++ src/models/DealerClaimDetails.ts | 258 ++++ src/models/DealerCompletionDetails.ts | 132 ++ src/models/DealerProposalCostItem.ts | 123 ++ src/models/DealerProposalDetails.ts | 146 ++ src/models/WorkflowRequest.ts | 27 + src/models/WorkflowTemplate.ts | 180 +++ src/models/index.ts | 12 +- src/routes/dealer.routes.ts | 38 + src/routes/dealerClaim.routes.ts | 78 ++ src/routes/index.ts | 6 + src/routes/template.routes.ts | 53 + src/scripts/migrate.ts | 10 + src/scripts/seed-dealers.ts | 182 +++ src/services/auth.service.ts | 351 +++-- src/services/dealer.service.ts | 166 +++ src/services/dealerClaim.service.ts | 964 +++++++++++++ src/services/dmsIntegration.service.ts | 313 +++++ src/services/enhancedTemplate.service.ts | 241 ++++ src/services/pause.service.ts | 42 +- src/services/sapIntegration.service.ts | 298 ++++ src/services/template.service.ts | 246 ++++ src/services/templateFieldResolver.service.ts | 287 ++++ src/types/auth.types.ts | 2 + 42 files changed, 8206 insertions(+), 130 deletions(-) create mode 100644 docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md create mode 100644 docs/COST_BREAKUP_TABLE_ARCHITECTURE.md create mode 100644 docs/DEALER_USER_ARCHITECTURE.md create mode 100644 docs/DYNAMIC_TEMPLATE_SYSTEM.md create mode 100644 docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md create mode 100644 docs/IMPLEMENTATION_PROGRESS.md create mode 100644 docs/IMPLEMENTATION_SUMMARY.md create mode 100644 docs/OKTA_USERS_API_INTEGRATION.md create mode 100644 src/controllers/dealer.controller.ts create mode 100644 src/controllers/dealerClaim.controller.ts create mode 100644 src/controllers/template.controller.ts create mode 100644 src/migrations/20251210-add-template-id-foreign-key.ts create mode 100644 src/migrations/20251210-add-workflow-type-support.ts create mode 100644 src/migrations/20251210-create-dealer-claim-tables.ts create mode 100644 src/migrations/20251210-create-proposal-cost-items-table.ts create mode 100644 src/migrations/20251210-enhance-workflow-templates.ts create mode 100644 src/models/DealerClaimDetails.ts create mode 100644 src/models/DealerCompletionDetails.ts create mode 100644 src/models/DealerProposalCostItem.ts create mode 100644 src/models/DealerProposalDetails.ts create mode 100644 src/models/WorkflowTemplate.ts create mode 100644 src/routes/dealer.routes.ts create mode 100644 src/routes/dealerClaim.routes.ts create mode 100644 src/routes/template.routes.ts create mode 100644 src/scripts/seed-dealers.ts create mode 100644 src/services/dealer.service.ts create mode 100644 src/services/dealerClaim.service.ts create mode 100644 src/services/dmsIntegration.service.ts create mode 100644 src/services/enhancedTemplate.service.ts create mode 100644 src/services/sapIntegration.service.ts create mode 100644 src/services/template.service.ts create mode 100644 src/services/templateFieldResolver.service.ts diff --git a/docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md b/docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md new file mode 100644 index 0000000..32dbdbc --- /dev/null +++ b/docs/CLAIM_MANAGEMENT_APPROVER_MAPPING.md @@ -0,0 +1,134 @@ +# Claim Management - Approver Mapping Documentation + +## Overview + +The Claim Management workflow has **8 fixed steps** with specific approvers and action types. This document explains how approvers are mapped when a claim request is created. + +## 8-Step Workflow Structure + +### Step 1: Dealer Proposal Submission +- **Approver Type**: Dealer (External) +- **Action Type**: **SUBMIT** (Dealer submits proposal documents) +- **TAT**: 72 hours +- **Mapping**: Uses `dealerEmail` from claim data +- **Status**: PENDING (waiting for dealer to submit) + +### Step 2: Requestor Evaluation +- **Approver Type**: Initiator (Internal RE Employee) +- **Action Type**: **APPROVE/REJECT** (Requestor reviews dealer proposal) +- **TAT**: 48 hours +- **Mapping**: Uses `initiatorId` (the person who created the request) +- **Status**: PENDING (waiting for requestor to evaluate) + +### Step 3: Department Lead Approval +- **Approver Type**: Department Lead (Internal RE Employee) +- **Action Type**: **APPROVE/REJECT** (Department lead approves and blocks IO budget) +- **TAT**: 72 hours +- **Mapping**: + - Option 1: Find user with role `MANAGEMENT` in same department as initiator + - Option 2: Use initiator's `manager` field from User model + - Option 3: Find user with designation containing "Lead" or "Head" in same department +- **Status**: PENDING (waiting for department lead approval) + +### Step 4: Activity Creation +- **Approver Type**: System (Auto-processed) +- **Action Type**: **AUTO** (System automatically creates activity) +- **TAT**: 1 hour +- **Mapping**: System user (`system@royalenfield.com`) +- **Status**: Auto-approved when triggered + +### Step 5: Dealer Completion Documents +- **Approver Type**: Dealer (External) +- **Action Type**: **SUBMIT** (Dealer submits completion documents) +- **TAT**: 120 hours +- **Mapping**: Uses `dealerEmail` from claim data +- **Status**: PENDING (waiting for dealer to submit) + +### Step 6: Requestor Claim Approval +- **Approver Type**: Initiator (Internal RE Employee) +- **Action Type**: **APPROVE/REJECT** (Requestor approves completion) +- **TAT**: 48 hours +- **Mapping**: Uses `initiatorId` +- **Status**: PENDING (waiting for requestor approval) + +### Step 7: E-Invoice Generation +- **Approver Type**: System (Auto-processed via DMS) +- **Action Type**: **AUTO** (System generates e-invoice via DMS integration) +- **TAT**: 1 hour +- **Mapping**: System user (`system@royalenfield.com`) +- **Status**: Auto-approved when triggered + +### Step 8: Credit Note Confirmation +- **Approver Type**: Finance Team (Internal RE Employee) +- **Action Type**: **APPROVE/REJECT** (Finance confirms credit note) +- **TAT**: 48 hours +- **Mapping**: + - Option 1: Find user with role `MANAGEMENT` and department contains "Finance" + - Option 2: Find user with designation containing "Finance" or "Accountant" + - Option 3: Use configured finance team email from admin settings +- **Status**: PENDING (waiting for finance confirmation) +- **Is Final Approver**: Yes (final step) + +## Current Implementation Issues + +### Problems: +1. **Step 1 & 5**: Dealer email not being used - using placeholder UUID +2. **Step 3**: Department Lead not resolved - using placeholder UUID +3. **Step 8**: Finance team not resolved - using placeholder UUID +4. **All steps**: Using initiator email for non-initiator steps + +### Impact: +- Steps 1, 3, 5, 8 won't have correct approvers assigned +- Notifications won't be sent to correct users +- Workflow will be stuck waiting for non-existent approvers + +## Action Types Summary + +| Step | Action Type | Description | +|------|-------------|-------------| +| 1 | SUBMIT | Dealer submits proposal (not approve/reject) | +| 2 | APPROVE/REJECT | Requestor evaluates proposal | +| 3 | APPROVE/REJECT | Department Lead approves and blocks budget | +| 4 | AUTO | System creates activity automatically | +| 5 | SUBMIT | Dealer submits completion documents | +| 6 | APPROVE/REJECT | Requestor approves completion | +| 7 | AUTO | System generates e-invoice via DMS | +| 8 | APPROVE/REJECT | Finance confirms credit note (FINAL) | + +## Approver Resolution Logic + +### For Dealer Steps (1, 5): +```typescript +// Use dealer email from claim data +const dealerEmail = claimData.dealerEmail; +// Find or create dealer user (if dealer is external, may need special handling) +const dealerUser = await User.findOne({ where: { email: dealerEmail } }); +// If dealer doesn't exist in system, create participant entry +``` + +### For Department Lead (Step 3): +```typescript +// Priority order: +1. Find user with same department and role = 'MANAGEMENT' +2. Use initiator.manager field to find manager +3. Find user with designation containing "Lead" or "Head" in same department +4. Fallback: Use initiator's manager email from User model +``` + +### For Finance Team (Step 8): +```typescript +// Priority order: +1. Find user with department containing "Finance" and role = 'MANAGEMENT' +2. Find user with designation containing "Finance" or "Accountant" +3. Use configured finance team email from admin_configurations table +4. Fallback: Use default finance email (e.g., finance@royalenfield.com) +``` + +## Next Steps + +The `createClaimApprovalLevels()` method needs to be updated to: +1. Accept `dealerEmail` parameter +2. Resolve Department Lead dynamically +3. Resolve Finance team member dynamically +4. Handle cases where approvers don't exist in the system + diff --git a/docs/COST_BREAKUP_TABLE_ARCHITECTURE.md b/docs/COST_BREAKUP_TABLE_ARCHITECTURE.md new file mode 100644 index 0000000..aa13760 --- /dev/null +++ b/docs/COST_BREAKUP_TABLE_ARCHITECTURE.md @@ -0,0 +1,149 @@ +# Cost Breakup Table Architecture + +## Overview + +This document describes the enhanced architecture for storing cost breakups in the Dealer Claim Management system. Instead of storing cost breakups as JSONB arrays, we now use a dedicated relational table for better querying, reporting, and data integrity. + +## Architecture Decision + +### Previous Approach (JSONB) +- **Storage**: Cost breakups stored as JSONB array in `dealer_proposal_details.cost_breakup` +- **Limitations**: + - Difficult to query individual cost items + - Hard to update specific items + - Not ideal for reporting and analytics + - No referential integrity + +### New Approach (Separate Table) +- **Storage**: Dedicated `dealer_proposal_cost_items` table +- **Benefits**: + - Better querying and filtering capabilities + - Easier to update individual cost items + - Better for analytics and reporting + - Maintains referential integrity + - Supports proper ordering of items + +## Database Schema + +### Table: `dealer_proposal_cost_items` + +```sql +CREATE TABLE dealer_proposal_cost_items ( + cost_item_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL REFERENCES dealer_proposal_details(proposal_id) ON DELETE CASCADE, + request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE, + item_description VARCHAR(500) NOT NULL, + amount DECIMAL(15, 2) NOT NULL, + item_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +**Indexes**: +- `idx_proposal_cost_items_proposal_id` on `proposal_id` +- `idx_proposal_cost_items_request_id` on `request_id` +- `idx_proposal_cost_items_proposal_order` on `(proposal_id, item_order)` + +## Backward Compatibility + +The system maintains backward compatibility by: +1. **Dual Storage**: Still saves cost breakups to JSONB field for backward compatibility +2. **Smart Retrieval**: When fetching proposal details: + - First tries to get cost items from the new table + - Falls back to JSONB field if table is empty +3. **Migration**: Automatically migrates existing JSONB data to the new table during migration + +## API Response Format + +The API always returns cost breakups as an array, regardless of storage method: + +```json +{ + "proposalDetails": { + "proposalId": "uuid", + "costBreakup": [ + { + "description": "Item 1", + "amount": 10000 + }, + { + "description": "Item 2", + "amount": 20000 + } + ], + "costItems": [ + { + "costItemId": "uuid", + "itemDescription": "Item 1", + "amount": 10000, + "itemOrder": 0 + } + ] + } +} +``` + +## Implementation Details + +### Saving Cost Items + +When a proposal is submitted: + +1. Save proposal details to `dealer_proposal_details` (with JSONB for backward compatibility) +2. Delete existing cost items for the proposal (if updating) +3. Insert new cost items into `dealer_proposal_cost_items` table +4. Items are ordered by `itemOrder` field + +### Retrieving Cost Items + +When fetching proposal details: + +1. Query `dealer_proposal_details` with `include` for `costItems` +2. If cost items exist in the table, use them +3. If not, fall back to parsing JSONB `costBreakup` field +4. Always return as a normalized array format + +## Migration + +The migration (`20251210-create-proposal-cost-items-table.ts`): +1. Creates the new table +2. Creates indexes for performance +3. Migrates existing JSONB data to the new table automatically +4. Handles errors gracefully (doesn't fail if migration of existing data fails) + +## Model Associations + +```typescript +DealerProposalDetails.hasMany(DealerProposalCostItem, { + as: 'costItems', + foreignKey: 'proposalId', + sourceKey: 'proposalId' +}); + +DealerProposalCostItem.belongsTo(DealerProposalDetails, { + as: 'proposal', + foreignKey: 'proposalId', + targetKey: 'proposalId' +}); +``` + +## Benefits for Frontend + +1. **Consistent Format**: Always receives cost breakups as an array +2. **No Changes Required**: Frontend code doesn't need to change +3. **Better Performance**: Can query specific cost items if needed +4. **Future Extensibility**: Easy to add features like: + - Cost item categories + - Approval status per item + - Historical tracking of cost changes + +## Future Enhancements + +Potential future improvements: +- Add `category` field to cost items +- Add `approved_amount` vs `requested_amount` for budget approval workflows +- Add `notes` field for item-level comments +- Add audit trail for cost item changes +- Add `is_approved` flag for individual item approval + diff --git a/docs/DEALER_USER_ARCHITECTURE.md b/docs/DEALER_USER_ARCHITECTURE.md new file mode 100644 index 0000000..d5ba946 --- /dev/null +++ b/docs/DEALER_USER_ARCHITECTURE.md @@ -0,0 +1,134 @@ +# Dealer User Architecture + +## Overview + +**Dealers and regular users are stored in the SAME `users` table.** This is the correct approach because dealers ARE users in the system - they login via SSO, participate in workflows, receive notifications, etc. + +## Why Single Table? + +### ✅ Advantages: +1. **Unified Authentication**: Dealers login via the same Okta SSO as regular users +2. **Shared Functionality**: Dealers need all user features (notifications, workflow participation, etc.) +3. **Simpler Architecture**: No need for joins or complex queries +4. **Data Consistency**: Single source of truth for all users +5. **Workflow Integration**: Dealers can be approvers, participants, or action takers seamlessly + +### ❌ Why NOT Separate Table: +- Would require complex joins for every query +- Data duplication (email, name, etc. in both tables) +- Dealers still need user authentication and permissions +- More complex to maintain + +## How Dealers Are Identified + +Dealers are identified using **three criteria** (any one matches): + +1. **`employeeId` field starts with `'RE-'`** (e.g., `RE-MH-001`, `RE-DL-002`) + - This is the **primary identifier** for dealers + - Dealer code is stored in `employeeId` field + +2. **`designation` contains `'dealer'`** (case-insensitive) + - Example: `"Dealer"`, `"Senior Dealer"`, etc. + +3. **`department` contains `'dealer'`** (case-insensitive) + - Example: `"Dealer Operations"`, `"Dealer Management"`, etc. + +## Database Schema + +```sql +users { + user_id UUID PK + email VARCHAR(255) UNIQUE + okta_sub VARCHAR(100) UNIQUE -- From Okta SSO + employee_id VARCHAR(50) -- For dealers: stores dealer code (RE-MH-001) + display_name VARCHAR(255) + designation VARCHAR(255) -- For dealers: "Dealer" + department VARCHAR(255) -- For dealers: "Dealer Operations" + role ENUM('USER', 'MANAGEMENT', 'ADMIN') + is_active BOOLEAN + -- ... other user fields +} +``` + +## Example Data + +### Regular User: +```json +{ + "userId": "uuid-1", + "email": "john.doe@royalenfield.com", + "employeeId": "E12345", // Regular employee ID + "designation": "Software Engineer", + "department": "IT", + "role": "USER" +} +``` + +### Dealer User: +```json +{ + "userId": "uuid-2", + "email": "test.2@royalenfield.com", + "employeeId": "RE-MH-001", // Dealer code stored here + "designation": "Dealer", + "department": "Dealer Operations", + "role": "USER" +} +``` + +## Querying Dealers + +The `dealer.service.ts` uses these filters to find dealers: + +```typescript +User.findAll({ + where: { + [Op.or]: [ + { designation: { [Op.iLike]: '%dealer%' } }, + { employeeId: { [Op.like]: 'RE-%' } }, + { department: { [Op.iLike]: '%dealer%' } }, + ], + isActive: true, + } +}); +``` + +## Seed Script Behavior + +When running `npm run seed:dealers`: + +1. **If user exists (from Okta SSO)**: + - ✅ Preserves `oktaSub` (real Okta subject ID) + - ✅ Preserves `role` (from Okta) + - ✅ Updates `employeeId` with dealer code + - ✅ Updates `designation` to "Dealer" (if not already) + - ✅ Updates `department` to "Dealer Operations" (if not already) + +2. **If user doesn't exist**: + - Creates placeholder user + - Sets `oktaSub` to `dealer-{code}-pending-sso` + - When dealer logs in via SSO, `oktaSub` gets updated automatically + +## Workflow Integration + +Dealers participate in workflows just like regular users: + +- **As Approvers**: In Steps 1 & 5 of claim management workflow +- **As Participants**: Can be added to any workflow +- **As Action Takers**: Can submit proposals, completion documents, etc. + +The system identifies them as dealers by checking `employeeId` starting with `'RE-'` or `designation` containing `'dealer'`. + +## API Endpoints + +- `GET /api/v1/dealers` - Get all dealers (filters users table) +- `GET /api/v1/dealers/code/:dealerCode` - Get dealer by code +- `GET /api/v1/dealers/email/:email` - Get dealer by email +- `GET /api/v1/dealers/search?q=term` - Search dealers + +All endpoints query the same `users` table with dealer-specific filters. + +## Conclusion + +**✅ Single `users` table is the correct approach.** No separate dealer table needed. Dealers are users with special identification markers (dealer code in `employeeId`, dealer designation, etc.). + diff --git a/docs/DYNAMIC_TEMPLATE_SYSTEM.md b/docs/DYNAMIC_TEMPLATE_SYSTEM.md new file mode 100644 index 0000000..fc41a2f --- /dev/null +++ b/docs/DYNAMIC_TEMPLATE_SYSTEM.md @@ -0,0 +1,1197 @@ +# Dynamic Multi-Step Template System +## Enhanced Admin Template Configuration with Dynamic User Field References + +## Overview + +This document outlines an enhanced template system that allows admins to: +1. **Create multi-step forms** (not just single-step) +2. **Dynamically reference user types** (initiator, dealer, approvers, team lead, etc.) +3. **Configure user details capture** per step +4. **Auto-populate user fields** based on workflow participants + +--- + +## Enhanced Database Schema + +### 1. Enhanced `workflow_templates` Table + +```sql +-- Migration: 20251210-enhance-workflow-templates.ts + +ALTER TABLE workflow_templates +ADD COLUMN IF NOT EXISTS form_steps_config JSONB, +ADD COLUMN IF NOT EXISTS user_field_mappings JSONB, +ADD COLUMN IF NOT EXISTS dynamic_approver_config JSONB; + +-- Example structure: +-- form_steps_config: Array of step definitions +-- user_field_mappings: Maps user roles to field references +-- dynamic_approver_config: Configuration for dynamic approver selection +``` + +### 2. Template Form Steps Configuration + +```typescript +// Structure for form_steps_config JSONB + +interface FormStepConfig { + stepNumber: number; + stepName: string; + stepDescription?: string; + fields: FormFieldConfig[]; + validationRules?: ValidationRule[]; + conditionalLogic?: ConditionalLogic[]; + userReferences?: UserReferenceConfig[]; +} + +interface FormFieldConfig { + fieldId: string; // Unique identifier + fieldType: 'text' | 'textarea' | 'select' | 'date' | 'number' | 'file' | 'user_picker' | 'dealer_picker' | 'approver_picker'; + label: string; + placeholder?: string; + required: boolean; + defaultValue?: any; + options?: Array<{ value: string; label: string }>; // For select fields + validation?: { + min?: number; + max?: number; + pattern?: string; + customValidator?: string; + }; + userReference?: { + role: 'initiator' | 'dealer' | 'approver' | 'team_lead' | 'department_lead' | 'current_approver' | 'previous_approver'; + level?: number; // For approver: which approval level + field: 'name' | 'email' | 'phone' | 'department' | 'employee_id' | 'all'; // Which user field to reference + autoPopulate: boolean; // Auto-fill from user data + editable: boolean; // Can user edit the auto-populated value + }; + visibility?: { + dependsOn?: string; // Field ID this depends on + condition: 'equals' | 'not_equals' | 'contains' | 'greater_than' | 'less_than'; + value: any; + }; +} + +interface UserReferenceConfig { + role: string; + captureFields: string[]; // Which fields to capture: ['name', 'email', 'phone', 'department'] + autoPopulateFrom: 'workflow' | 'user_profile' | 'approval_level'; // Source of user data + allowOverride: boolean; // Can user override auto-populated values +} +``` + +### 3. Dynamic Approver Configuration + +```typescript +interface DynamicApproverConfig { + enabled: boolean; + approverSelection: { + mode: 'static' | 'dynamic' | 'role_based' | 'department_based'; + staticApprovers?: Array<{ + level: number; + userId?: string; + userEmail?: string; + role?: string; + }>; + dynamicRules?: Array<{ + level: number; + selectionCriteria: { + type: 'role' | 'department' | 'manager' | 'custom'; + value: string; + fallback?: string; + }; + captureUserDetails: boolean; // Capture approver details in form + captureFields: string[]; // Which fields to capture + }>; + }; + approverDetailsInSteps: Array<{ + stepNumber: number; + approverLevel: number; + captureFields: string[]; + displayMode: 'readonly' | 'editable' | 'hidden'; + }>; +} +``` + +--- + +## Example Template Configurations + +### Example 1: Claim Management Template (8-Step) + +```json +{ + "template_name": "Dealer Claim Management", + "template_code": "CLAIM_MANAGEMENT", + "workflow_type": "CLAIM_MANAGEMENT", + "form_steps_config": [ + { + "stepNumber": 1, + "stepName": "Basic Information", + "stepDescription": "Capture activity and dealer information", + "fields": [ + { + "fieldId": "activity_name", + "fieldType": "text", + "label": "Activity Name", + "required": true, + "validation": { + "min": 3, + "max": 500 + } + }, + { + "fieldId": "activity_type", + "fieldType": "select", + "label": "Activity Type", + "required": true, + "options": [ + { "value": "RIDERS_MANIA", "label": "Riders Mania Claims" }, + { "value": "MARKETING_COST", "label": "Marketing Cost – Bike to Vendor" }, + { "value": "MEDIA_BIKE_SERVICE", "label": "Media Bike Service" } + ] + }, + { + "fieldId": "dealer_code", + "fieldType": "dealer_picker", + "label": "Dealer Code", + "required": true, + "userReference": { + "role": "dealer", + "field": "all", + "autoPopulate": false, + "editable": false + } + }, + { + "fieldId": "dealer_name", + "fieldType": "text", + "label": "Dealer Name", + "required": true, + "userReference": { + "role": "dealer", + "field": "name", + "autoPopulate": true, + "editable": false + } + }, + { + "fieldId": "dealer_email", + "fieldType": "text", + "label": "Dealer Email", + "required": true, + "userReference": { + "role": "dealer", + "field": "email", + "autoPopulate": true, + "editable": true + } + }, + { + "fieldId": "initiator_name", + "fieldType": "text", + "label": "Request Initiator", + "required": false, + "userReference": { + "role": "initiator", + "field": "name", + "autoPopulate": true, + "editable": false + }, + "visibility": { + "dependsOn": null, + "condition": "always_visible" + } + }, + { + "fieldId": "activity_date", + "fieldType": "date", + "label": "Activity Date", + "required": true + }, + { + "fieldId": "location", + "fieldType": "text", + "label": "Location", + "required": true + } + ], + "userReferences": [ + { + "role": "initiator", + "captureFields": ["name", "email", "department"], + "autoPopulateFrom": "user_profile", + "allowOverride": false + }, + { + "role": "dealer", + "captureFields": ["name", "email", "phone", "address"], + "autoPopulateFrom": "workflow", + "allowOverride": true + } + ] + }, + { + "stepNumber": 2, + "stepName": "Dealer Proposal Submission", + "stepDescription": "Dealer submits proposal with cost breakdown", + "fields": [ + { + "fieldId": "proposal_document", + "fieldType": "file", + "label": "Proposal Document", + "required": true, + "validation": { + "accept": ".pdf,.doc,.docx", + "maxSize": 10485760 + } + }, + { + "fieldId": "cost_breakup", + "fieldType": "textarea", + "label": "Cost Breakup (JSON)", + "required": true + }, + { + "fieldId": "dealer_comments", + "fieldType": "textarea", + "label": "Dealer Comments", + "required": true + }, + { + "fieldId": "dealer_submitted_by", + "fieldType": "text", + "label": "Submitted By", + "required": false, + "userReference": { + "role": "dealer", + "field": "name", + "autoPopulate": true, + "editable": false + } + } + ], + "userReferences": [ + { + "role": "dealer", + "captureFields": ["name", "email"], + "autoPopulateFrom": "workflow", + "allowOverride": false + } + ] + }, + { + "stepNumber": 3, + "stepName": "Requestor Evaluation", + "stepDescription": "Initiator reviews and confirms proposal", + "fields": [ + { + "fieldId": "evaluation_comments", + "fieldType": "textarea", + "label": "Evaluation Comments", + "required": true + }, + { + "fieldId": "requestor_name", + "fieldType": "text", + "label": "Evaluated By", + "required": false, + "userReference": { + "role": "initiator", + "field": "name", + "autoPopulate": true, + "editable": false + } + }, + { + "fieldId": "requestor_department", + "fieldType": "text", + "label": "Department", + "required": false, + "userReference": { + "role": "initiator", + "field": "department", + "autoPopulate": true, + "editable": false + } + } + ], + "userReferences": [ + { + "role": "initiator", + "captureFields": ["name", "email", "department"], + "autoPopulateFrom": "user_profile", + "allowOverride": false + } + ] + }, + { + "stepNumber": 4, + "stepName": "Department Lead Approval", + "stepDescription": "Department lead approves and blocks IO budget", + "fields": [ + { + "fieldId": "io_number", + "fieldType": "text", + "label": "IO Number", + "required": true + }, + { + "fieldId": "amount_to_block", + "fieldType": "number", + "label": "Amount to Block", + "required": true, + "validation": { + "min": 0 + } + }, + { + "fieldId": "dept_lead_name", + "fieldType": "text", + "label": "Approved By", + "required": false, + "userReference": { + "role": "approver", + "level": 3, + "field": "name", + "autoPopulate": true, + "editable": false + } + }, + { + "fieldId": "dept_lead_email", + "fieldType": "text", + "label": "Department Lead Email", + "required": false, + "userReference": { + "role": "approver", + "level": 3, + "field": "email", + "autoPopulate": true, + "editable": false + } + }, + { + "fieldId": "dept_lead_department", + "fieldType": "text", + "label": "Department", + "required": false, + "userReference": { + "role": "approver", + "level": 3, + "field": "department", + "autoPopulate": true, + "editable": false + } + } + ], + "userReferences": [ + { + "role": "approver", + "captureFields": ["name", "email", "department"], + "autoPopulateFrom": "approval_level", + "allowOverride": false + } + ] + } + // ... more steps + ], + "dynamic_approver_config": { + "enabled": true, + "approverSelection": { + "mode": "role_based", + "dynamicRules": [ + { + "level": 3, + "selectionCriteria": { + "type": "role", + "value": "DEPARTMENT_LEAD", + "fallback": "MANAGEMENT" + }, + "captureUserDetails": true, + "captureFields": ["name", "email", "department", "employee_id"] + }, + { + "level": 4, + "selectionCriteria": { + "type": "department", + "value": "${initiator.department}", + "fallback": null + }, + "captureUserDetails": true, + "captureFields": ["name", "email"] + } + ] + }, + "approverDetailsInSteps": [ + { + "stepNumber": 4, + "approverLevel": 3, + "captureFields": ["name", "email", "department"], + "displayMode": "readonly" + } + ] + } +} +``` + +### Example 2: Simple Vendor Payment Template (Admin-Created) + +```json +{ + "template_name": "Vendor Payment Request", + "template_code": "VENDOR_PAYMENT", + "workflow_type": "VENDOR_PAYMENT", + "form_steps_config": [ + { + "stepNumber": 1, + "stepName": "Vendor Details", + "fields": [ + { + "fieldId": "vendor_name", + "fieldType": "text", + "label": "Vendor Name", + "required": true + }, + { + "fieldId": "invoice_number", + "fieldType": "text", + "label": "Invoice Number", + "required": true + }, + { + "fieldId": "initiator_info", + "fieldType": "text", + "label": "Requested By", + "required": false, + "userReference": { + "role": "initiator", + "field": "all", + "autoPopulate": true, + "editable": false + } + } + ] + }, + { + "stepNumber": 2, + "stepName": "Team Lead Approval", + "fields": [ + { + "fieldId": "team_lead_name", + "fieldType": "text", + "label": "Team Lead", + "required": false, + "userReference": { + "role": "approver", + "level": 1, + "field": "name", + "autoPopulate": true, + "editable": false + } + }, + { + "fieldId": "team_lead_approval", + "fieldType": "select", + "label": "Approval Status", + "required": true, + "options": [ + { "value": "approved", "label": "Approved" }, + { "value": "rejected", "label": "Rejected" } + ] + } + ] + } + ], + "dynamic_approver_config": { + "enabled": true, + "approverSelection": { + "mode": "role_based", + "dynamicRules": [ + { + "level": 1, + "selectionCriteria": { + "type": "role", + "value": "TEAM_LEAD", + "fallback": "MANAGEMENT" + }, + "captureUserDetails": true, + "captureFields": ["name", "email", "department"] + } + ] + } + } +} +``` + +--- + +## Service Layer Implementation + +### 1. Template Service with Dynamic Field Resolution + +```typescript +// Re_Backend/src/services/templateFieldResolver.service.ts + +import { WorkflowRequest } from '../models/WorkflowRequest'; +import { ApprovalLevel } from '../models/ApprovalLevel'; +import { User } from '../models/User'; +import { Participant } from '../models/Participant'; + +export class TemplateFieldResolver { + /** + * Resolve user reference fields in a step + */ + async resolveUserReferences( + stepConfig: FormStepConfig, + request: WorkflowRequest, + currentUserId: string, + context?: { + currentLevel?: number; + approvers?: Map; + } + ): Promise> { + const resolvedFields: Record = {}; + + for (const field of stepConfig.fields) { + if (field.userReference) { + const userData = await this.getUserDataForReference( + field.userReference, + request, + currentUserId, + context + ); + + if (field.userReference.autoPopulate) { + resolvedFields[field.fieldId] = this.extractUserField( + userData, + field.userReference.field + ); + } + } + } + + return resolvedFields; + } + + /** + * Get user data based on reference configuration + */ + private async getUserDataForReference( + userRef: UserReference, + request: WorkflowRequest, + currentUserId: string, + context?: any + ): Promise { + switch (userRef.role) { + case 'initiator': + return await User.findByPk(request.initiatorId); + + case 'dealer': + // Get dealer from participants or claim details + const dealerParticipant = await Participant.findOne({ + where: { + requestId: request.requestId, + participantType: 'DEALER' + }, + include: [{ model: User, as: 'user' }] + }); + return dealerParticipant?.user || null; + + case 'approver': + if (userRef.level && context?.approvers) { + const approverLevel = context.approvers.get(userRef.level); + if (approverLevel?.approverId) { + return await User.findByPk(approverLevel.approverId); + } + } + // Fallback to current approver + const currentLevel = await ApprovalLevel.findOne({ + where: { + requestId: request.requestId, + levelNumber: context?.currentLevel || request.currentLevel + } + }); + if (currentLevel?.approverId) { + return await User.findByPk(currentLevel.approverId); + } + return null; + + case 'team_lead': + // Find team lead based on initiator's department or manager + const initiator = await User.findByPk(request.initiatorId); + if (initiator?.manager) { + return await User.findOne({ + where: { + email: initiator.manager, + role: 'MANAGEMENT' // or specific role check + } + }); + } + return null; + + case 'department_lead': + const initiatorUser = await User.findByPk(request.initiatorId); + if (initiatorUser?.department) { + return await User.findOne({ + where: { + department: initiatorUser.department, + role: 'MANAGEMENT' + }, + order: [['created_at', 'DESC']] + }); + } + return null; + + case 'current_approver': + const currentApprovalLevel = await ApprovalLevel.findOne({ + where: { + requestId: request.requestId, + status: 'PENDING' + }, + order: [['level_number', 'ASC']] + }); + if (currentApprovalLevel?.approverId) { + return await User.findByPk(currentApprovalLevel.approverId); + } + return null; + + case 'previous_approver': + const previousLevel = request.currentLevel - 1; + const previousApprovalLevel = await ApprovalLevel.findOne({ + where: { + requestId: request.requestId, + levelNumber: previousLevel + } + }); + if (previousApprovalLevel?.approverId) { + return await User.findByPk(previousApprovalLevel.approverId); + } + return null; + + default: + return null; + } + } + + /** + * Extract specific field from user data + */ + private extractUserField(user: User | null, field: string): any { + if (!user) return null; + + switch (field) { + case 'name': + return user.displayName || `${user.firstName} ${user.lastName}`; + case 'email': + return user.email; + case 'phone': + return user.phone || user.mobilePhone; + case 'department': + return user.department; + case 'employee_id': + return user.employeeId; + case 'all': + return { + name: user.displayName, + email: user.email, + phone: user.phone || user.mobilePhone, + department: user.department, + employeeId: user.employeeId + }; + default: + return null; + } + } + + /** + * Resolve dynamic approver based on configuration + */ + async resolveDynamicApprover( + level: number, + config: DynamicApproverConfig, + request: WorkflowRequest + ): Promise { + if (!config.enabled || !config.approverSelection.dynamicRules) { + return null; + } + + const rule = config.approverSelection.dynamicRules.find(r => r.level === level); + if (!rule) return null; + + const criteria = rule.selectionCriteria; + + switch (criteria.type) { + case 'role': + return await User.findOne({ + where: { + role: criteria.value as any + }, + order: [['created_at', 'DESC']] + }); + + case 'department': + const initiator = await User.findByPk(request.initiatorId); + const deptValue = criteria.value.replace('${initiator.department}', initiator?.department || ''); + return await User.findOne({ + where: { + department: deptValue, + role: 'MANAGEMENT' + } + }); + + case 'manager': + const initiatorUser = await User.findByPk(request.initiatorId); + if (initiatorUser?.manager) { + return await User.findOne({ + where: { + email: initiatorUser.manager + } + }); + } + return null; + + default: + return null; + } + } +} +``` + +### 2. Enhanced Template Service + +```typescript +// Re_Backend/src/services/enhancedTemplate.service.ts + +import { WorkflowTemplate } from '../models/WorkflowTemplate'; +import { TemplateFieldResolver } from './templateFieldResolver.service'; + +export class EnhancedTemplateService { + private fieldResolver = new TemplateFieldResolver(); + + /** + * Get form configuration for a template with resolved user references + */ + async getFormConfig( + templateId: string, + requestId?: string, + currentUserId?: string + ): Promise { + const template = await WorkflowTemplate.findByPk(templateId); + if (!template) throw new Error('Template not found'); + + const stepsConfig = template.formStepsConfig as FormStepConfig[]; + + // If request exists, resolve user references + if (requestId && currentUserId) { + const request = await WorkflowRequest.findByPk(requestId); + if (request) { + return await this.resolveStepsWithUserData(stepsConfig, request, currentUserId); + } + } + + return stepsConfig; + } + + /** + * Resolve user references in all steps + */ + private async resolveStepsWithUserData( + steps: FormStepConfig[], + request: WorkflowRequest, + currentUserId: string + ): Promise { + // Get all approvers for context + const approvers = await ApprovalLevel.findAll({ + where: { requestId: request.requestId } + }); + const approverMap = new Map( + approvers.map(a => [a.levelNumber, a]) + ); + + const resolvedSteps = await Promise.all( + steps.map(async (step) => { + const resolvedFields = await this.fieldResolver.resolveUserReferences( + step, + request, + currentUserId, + { + currentLevel: request.currentLevel, + approvers: approverMap + } + ); + + // Merge resolved values into field defaults + const enrichedFields = step.fields.map(field => ({ + ...field, + defaultValue: resolvedFields[field.fieldId] || field.defaultValue + })); + + return { + ...step, + fields: enrichedFields + }; + }) + ); + + return resolvedSteps; + } + + /** + * Validate and save form data for a step + */ + async saveStepData( + templateId: string, + requestId: string, + stepNumber: number, + formData: Record, + userId: string + ): Promise { + const template = await WorkflowTemplate.findByPk(templateId); + const stepsConfig = template.formStepsConfig as FormStepConfig[]; + const stepConfig = stepsConfig.find(s => s.stepNumber === stepNumber); + + if (!stepConfig) { + throw new Error(`Step ${stepNumber} not found in template`); + } + + // Validate required fields + this.validateStepData(stepConfig, formData); + + // Save to template-specific storage + await this.saveToTemplateStorage(template.workflowType, requestId, stepNumber, formData); + } + + private validateStepData(stepConfig: FormStepConfig, formData: Record): void { + for (const field of stepConfig.fields) { + if (field.required && !formData[field.fieldId]) { + throw new Error(`Field ${field.label} is required`); + } + + // Apply validation rules + if (field.validation && formData[field.fieldId]) { + const value = formData[field.fieldId]; + if (field.validation.min && value < field.validation.min) { + throw new Error(`${field.label} must be at least ${field.validation.min}`); + } + if (field.validation.max && value > field.validation.max) { + throw new Error(`${field.label} must be at most ${field.validation.max}`); + } + } + } + } + + private async saveToTemplateStorage( + workflowType: string, + requestId: string, + stepNumber: number, + formData: Record + ): Promise { + // Save to appropriate extension table based on workflow type + switch (workflowType) { + case 'CLAIM_MANAGEMENT': + await this.saveClaimManagementStepData(requestId, stepNumber, formData); + break; + case 'VENDOR_PAYMENT': + await this.saveVendorPaymentStepData(requestId, stepNumber, formData); + break; + default: + // Generic storage for custom templates + await this.saveGenericStepData(requestId, stepNumber, formData); + } + } + + private async saveClaimManagementStepData( + requestId: string, + stepNumber: number, + formData: Record + ): Promise { + switch (stepNumber) { + case 1: + // Save to dealer_claim_details + await DealerClaimDetails.upsert({ + requestId, + activityName: formData.activity_name, + activityType: formData.activity_type, + dealerCode: formData.dealer_code, + dealerName: formData.dealer_name, + dealerEmail: formData.dealer_email, + // ... map all fields + }); + break; + case 2: + // Save to dealer_proposal_details + await DealerProposalDetails.upsert({ + requestId, + costBreakup: formData.cost_breakup, + dealerComments: formData.dealer_comments, + // ... + }); + break; + // ... other steps + } + } +} +``` + +--- + +## API Endpoints + +### 1. Get Template Form Configuration + +```typescript +// GET /api/v1/templates/:templateId/form-config +// Query params: ?requestId=xxx&stepNumber=1 + +async getTemplateFormConfig(req: Request, res: Response) { + const { templateId } = req.params; + const { requestId, stepNumber } = req.query; + const userId = req.user?.userId; + + const templateService = new EnhancedTemplateService(); + const formConfig = await templateService.getFormConfig( + templateId, + requestId as string, + userId + ); + + // Filter by step if specified + const steps = stepNumber + ? formConfig.filter(s => s.stepNumber === parseInt(stepNumber as string)) + : formConfig; + + return ResponseHandler.success(res, { steps }); +} +``` + +### 2. Save Step Data + +```typescript +// POST /api/v1/templates/:templateId/requests/:requestId/steps/:stepNumber + +async saveStepData(req: AuthenticatedRequest, res: Response) { + const { templateId, requestId, stepNumber } = req.params; + const formData = req.body; + const userId = req.user?.userId; + + const templateService = new EnhancedTemplateService(); + await templateService.saveStepData( + templateId, + requestId, + parseInt(stepNumber), + formData, + userId + ); + + return ResponseHandler.success(res, { message: 'Step data saved' }); +} +``` + +### 3. Get Resolved User Data + +```typescript +// GET /api/v1/templates/:templateId/user-references +// Query: ?role=approver&level=3&requestId=xxx + +async getUserReferenceData(req: Request, res: Response) { + const { templateId } = req.params; + const { role, level, requestId } = req.query; + + const template = await WorkflowTemplate.findByPk(templateId); + const request = requestId ? await WorkflowRequest.findByPk(requestId as string) : null; + + const fieldResolver = new TemplateFieldResolver(); + const userData = await fieldResolver.getUserDataForReference( + { + role: role as string, + level: level ? parseInt(level as string) : undefined, + field: 'all', + autoPopulate: true, + editable: false + }, + request!, + req.user?.userId || '' + ); + + return ResponseHandler.success(res, { userData }); +} +``` + +--- + +## Frontend Integration + +### 1. Dynamic Form Renderer + +```typescript +// Frontend: components/DynamicFormRenderer.tsx + +interface DynamicFormRendererProps { + stepConfig: FormStepConfig; + formData: Record; + onChange: (fieldId: string, value: any) => void; + userReferences?: Record; +} + +export function DynamicFormRenderer({ + stepConfig, + formData, + onChange, + userReferences +}: DynamicFormRendererProps) { + return ( +
+ {stepConfig.fields.map(field => { + // Auto-populate from user references + const value = userReferences?.[field.fieldId] || formData[field.fieldId] || field.defaultValue; + + return ( +
+ + {field.userReference?.autoPopulate && userReferences?.[field.fieldId] && ( + Auto-filled from {field.userReference.role} + )} + {renderField(field, value, onChange, field.userReference?.editable !== false)} +
+ ); + })} +
+ ); +} +``` + +### 2. Multi-Step Wizard + +```typescript +// Frontend: components/TemplateWizard.tsx + +export function TemplateWizard({ templateId, requestId }: Props) { + const [currentStep, setCurrentStep] = useState(1); + const [formData, setFormData] = useState>({}); + const [userReferences, setUserReferences] = useState>({}); + + // Fetch form config with resolved user references + const { data: formConfig } = useQuery( + ['template-form-config', templateId, requestId], + () => api.getTemplateFormConfig(templateId, { requestId }) + ); + + // Auto-populate user references when step changes + useEffect(() => { + if (formConfig?.steps) { + const step = formConfig.steps.find(s => s.stepNumber === currentStep); + if (step?.userReferences) { + // Fetch user reference data + step.userReferences.forEach(ref => { + api.getUserReferenceData(templateId, { + role: ref.role, + level: ref.level, + requestId + }).then(data => { + setUserReferences(prev => ({ + ...prev, + ...data.userData + })); + }); + }); + } + } + }, [currentStep, formConfig]); + + const handleSubmit = async () => { + await api.saveStepData(templateId, requestId, currentStep, formData); + // Move to next step or submit + }; + + return ( + + {formConfig?.steps.map(step => ( + + { + setFormData(prev => ({ ...prev, [fieldId]: value })); + }} + userReferences={userReferences} + /> + + ))} + + ); +} +``` + +--- + +## Admin UI for Template Creation + +### Template Builder Component + +```typescript +// Frontend: admin/TemplateBuilder.tsx + +export function TemplateBuilder() { + const [steps, setSteps] = useState([]); + const [currentStep, setCurrentStep] = useState(1); + + const addField = (stepNumber: number) => { + const newField: FormFieldConfig = { + fieldId: `field_${Date.now()}`, + fieldType: 'text', + label: 'New Field', + required: false + }; + + setSteps(prev => prev.map(step => + step.stepNumber === stepNumber + ? { ...step, fields: [...step.fields, newField] } + : step + )); + }; + + const configureUserReference = (stepNumber: number, fieldId: string) => { + // Open modal to configure user reference + // Allow admin to select: + // - Role (initiator, dealer, approver, team_lead, etc.) + // - Level (for approver) + // - Field to capture (name, email, all, etc.) + // - Auto-populate toggle + // - Editable toggle + }; + + return ( +
+ {/* ... */}} + onAddField={addField} + onConfigureUserReference={configureUserReference} + /> +
+ ); +} +``` + +--- + +## Summary + +This enhanced system provides: + +1. ✅ **Multi-step forms** - Admins can configure multiple steps per template +2. ✅ **Dynamic user references** - Fields can auto-populate from initiator, dealer, approvers, team lead, etc. +3. ✅ **Flexible approver selection** - Dynamic approver assignment based on role, department, manager +4. ✅ **Auto-population** - User details automatically filled based on workflow context +5. ✅ **Extensibility** - Easy to add new user reference types +6. ✅ **Admin-friendly** - Visual template builder for creating templates +7. ✅ **Type-safe** - Structured configuration with validation + +The system automatically: +- Captures initiator details when request is created +- Captures dealer details when dealer is selected +- Captures approver details when approver is assigned (dynamically or statically) +- Updates fields when approvers change +- Supports team lead, department lead, and custom role references + diff --git a/docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md b/docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md new file mode 100644 index 0000000..149e0d0 --- /dev/null +++ b/docs/EXTENSIBLE_WORKFLOW_ARCHITECTURE.md @@ -0,0 +1,583 @@ +# Extensible Workflow Architecture Plan +## Supporting Multiple Template Types (Claim Management, Non-Templatized, Future Templates) + +## Overview + +This document outlines how to design the backend architecture to support: +1. **Unified Request System**: All requests (templatized, non-templatized, claim management) use the same `workflow_requests` table +2. **Template Identification**: Distinguish between different workflow types +3. **Extensibility**: Easy addition of new templates by admins without code changes +4. **Unified Views**: All requests appear in "My Requests", "Open Requests", etc. automatically + +--- + +## Architecture Principles + +### 1. **Single Source of Truth: `workflow_requests` Table** + +All requests, regardless of type, are stored in the same table: + +```sql +workflow_requests { + request_id UUID PK + request_number VARCHAR(20) UK + initiator_id UUID FK + template_type VARCHAR(20) -- 'CUSTOM' | 'TEMPLATE' (high-level) + workflow_type VARCHAR(50) -- 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | 'DEALER_ONBOARDING' | etc. + template_id UUID FK (nullable) -- Reference to workflow_templates if using admin template + title VARCHAR(500) + description TEXT + status workflow_status + current_level INTEGER + total_levels INTEGER + -- ... common fields +} +``` + +**Key Fields:** +- `template_type`: High-level classification ('CUSTOM' for user-created, 'TEMPLATE' for admin templates) +- `workflow_type`: Specific workflow identifier (e.g., 'CLAIM_MANAGEMENT', 'NON_TEMPLATIZED') +- `template_id`: Optional reference to `workflow_templates` table if using an admin-created template + +### 2. **Template-Specific Data Storage** + +Each workflow type can have its own extension table for type-specific data: + +```sql +-- For Claim Management +dealer_claim_details { + claim_id UUID PK + request_id UUID FK -> workflow_requests(request_id) + activity_name VARCHAR(500) + activity_type VARCHAR(100) + dealer_code VARCHAR(50) + dealer_name VARCHAR(200) + dealer_email VARCHAR(255) + dealer_phone VARCHAR(20) + dealer_address TEXT + activity_date DATE + location VARCHAR(255) + period_start_date DATE + period_end_date DATE + estimated_budget DECIMAL(15,2) + closed_expenses DECIMAL(15,2) + io_number VARCHAR(50) + io_blocked_amount DECIMAL(15,2) + sap_document_number VARCHAR(100) + dms_number VARCHAR(100) + e_invoice_number VARCHAR(100) + credit_note_number VARCHAR(100) + -- ... claim-specific fields +} + +-- For Non-Templatized (if needed) +non_templatized_details { + detail_id UUID PK + request_id UUID FK -> workflow_requests(request_id) + custom_fields JSONB -- Flexible storage for any custom data + -- ... any specific fields +} + +-- For Future Templates +-- Each new template can have its own extension table +``` + +### 3. **Workflow Templates Table (Admin-Created Templates)** + +```sql +workflow_templates { + template_id UUID PK + template_name VARCHAR(200) -- Display name: "Claim Management", "Dealer Onboarding" + template_code VARCHAR(50) UK -- Unique identifier: "CLAIM_MANAGEMENT", "DEALER_ONBOARDING" + template_description TEXT + template_category VARCHAR(100) -- "Dealer Operations", "HR", "Finance", etc. + workflow_type VARCHAR(50) -- Maps to workflow_requests.workflow_type + approval_levels_config JSONB -- Step definitions, TAT, roles, etc. + default_tat_hours DECIMAL(10,2) + form_fields_config JSONB -- Form field definitions for wizard + is_active BOOLEAN + is_system_template BOOLEAN -- True for built-in (Claim Management), False for admin-created + created_by UUID FK + created_at TIMESTAMP + updated_at TIMESTAMP +} +``` + +--- + +## Database Schema Changes + +### Migration: Add Workflow Type Support + +```sql +-- Migration: 20251210-add-workflow-type-support.ts + +-- 1. Add workflow_type column to workflow_requests +ALTER TABLE workflow_requests +ADD COLUMN IF NOT EXISTS workflow_type VARCHAR(50) DEFAULT 'NON_TEMPLATIZED'; + +-- 2. Add template_id column (nullable, for admin templates) +ALTER TABLE workflow_requests +ADD COLUMN IF NOT EXISTS template_id UUID REFERENCES workflow_templates(template_id); + +-- 3. Create index for workflow_type +CREATE INDEX IF NOT EXISTS idx_workflow_requests_workflow_type +ON workflow_requests(workflow_type); + +-- 4. Create index for template_id +CREATE INDEX IF NOT EXISTS idx_workflow_requests_template_id +ON workflow_requests(template_id); + +-- 5. Create dealer_claim_details table +CREATE TABLE IF NOT EXISTS dealer_claim_details ( + claim_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + request_id UUID NOT NULL UNIQUE REFERENCES workflow_requests(request_id) ON DELETE CASCADE, + activity_name VARCHAR(500) NOT NULL, + activity_type VARCHAR(100) NOT NULL, + dealer_code VARCHAR(50) NOT NULL, + dealer_name VARCHAR(200) NOT NULL, + dealer_email VARCHAR(255), + dealer_phone VARCHAR(20), + dealer_address TEXT, + activity_date DATE, + location VARCHAR(255), + period_start_date DATE, + period_end_date DATE, + estimated_budget DECIMAL(15,2), + closed_expenses DECIMAL(15,2), + io_number VARCHAR(50), + io_available_balance DECIMAL(15,2), + io_blocked_amount DECIMAL(15,2), + io_remaining_balance DECIMAL(15,2), + sap_document_number VARCHAR(100), + dms_number VARCHAR(100), + e_invoice_number VARCHAR(100), + e_invoice_date DATE, + credit_note_number VARCHAR(100), + credit_note_date DATE, + credit_note_amount DECIMAL(15,2), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_dealer_claim_details_request_id ON dealer_claim_details(request_id); +CREATE INDEX idx_dealer_claim_details_dealer_code ON dealer_claim_details(dealer_code); + +-- 6. Create proposal_details table (Step 1: Dealer Proposal) +CREATE TABLE IF NOT EXISTS dealer_proposal_details ( + proposal_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE, + proposal_document_path VARCHAR(500), + proposal_document_url VARCHAR(500), + cost_breakup JSONB, -- Array of {description, amount} + total_estimated_budget DECIMAL(15,2), + timeline_mode VARCHAR(10), -- 'date' | 'days' + expected_completion_date DATE, + expected_completion_days INTEGER, + dealer_comments TEXT, + submitted_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_dealer_proposal_details_request_id ON dealer_proposal_details(request_id); + +-- 7. Create completion_documents table (Step 5: Dealer Completion) +CREATE TABLE IF NOT EXISTS dealer_completion_details ( + completion_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + request_id UUID NOT NULL REFERENCES workflow_requests(request_id) ON DELETE CASCADE, + activity_completion_date DATE NOT NULL, + number_of_participants INTEGER, + closed_expenses JSONB, -- Array of {description, amount} + total_closed_expenses DECIMAL(15,2), + completion_documents JSONB, -- Array of document references + activity_photos JSONB, -- Array of photo references + submitted_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_dealer_completion_details_request_id ON dealer_completion_details(request_id); +``` + +--- + +## Model Updates + +### 1. Update WorkflowRequest Model + +```typescript +// Re_Backend/src/models/WorkflowRequest.ts + +interface WorkflowRequestAttributes { + requestId: string; + requestNumber: string; + initiatorId: string; + templateType: 'CUSTOM' | 'TEMPLATE'; + workflowType: string; // NEW: 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc. + templateId?: string; // NEW: Reference to workflow_templates + title: string; + description: string; + // ... existing fields +} + +// Add association +WorkflowRequest.hasOne(DealerClaimDetails, { + as: 'claimDetails', + foreignKey: 'requestId', + sourceKey: 'requestId' +}); +``` + +### 2. Create DealerClaimDetails Model + +```typescript +// Re_Backend/src/models/DealerClaimDetails.ts + +import { DataTypes, Model } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface DealerClaimDetailsAttributes { + claimId: string; + requestId: string; + activityName: string; + activityType: string; + dealerCode: string; + dealerName: string; + // ... all claim-specific fields +} + +class DealerClaimDetails extends Model { + public claimId!: string; + public requestId!: string; + // ... fields +} + +DealerClaimDetails.init({ + claimId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'claim_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + // ... all other fields +}, { + sequelize, + modelName: 'DealerClaimDetails', + tableName: 'dealer_claim_details', + timestamps: true +}); + +// Association +DealerClaimDetails.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +export { DealerClaimDetails }; +``` + +--- + +## Service Layer Pattern + +### 1. Template-Aware Service Factory + +```typescript +// Re_Backend/src/services/templateService.factory.ts + +import { WorkflowRequest } from '../models/WorkflowRequest'; +import { DealerClaimService } from './dealerClaim.service'; +import { NonTemplatizedService } from './nonTemplatized.service'; + +export class TemplateServiceFactory { + static getService(workflowType: string) { + switch (workflowType) { + case 'CLAIM_MANAGEMENT': + return new DealerClaimService(); + case 'NON_TEMPLATIZED': + return new NonTemplatizedService(); + default: + // For future templates, use a generic service or throw error + throw new Error(`Unsupported workflow type: ${workflowType}`); + } + } + + static async getRequestDetails(requestId: string) { + const request = await WorkflowRequest.findByPk(requestId); + if (!request) return null; + + const service = this.getService(request.workflowType); + return service.getRequestDetails(request); + } +} +``` + +### 2. Unified Workflow Service (No Changes Needed) + +The existing `WorkflowService.listMyRequests()` and `listOpenForMe()` methods will **automatically** include all request types because they query `workflow_requests` table without filtering by `workflow_type`. + +```typescript +// Existing code works as-is - no changes needed! +async listMyRequests(userId: string, page: number, limit: number, filters?: {...}) { + // This query automatically includes ALL workflow types + const requests = await WorkflowRequest.findAll({ + where: { + initiatorId: userId, + isDraft: false, + // ... filters + // NO workflow_type filter - includes everything! + } + }); + return requests; +} +``` + +--- + +## API Endpoints + +### 1. Create Claim Management Request + +```typescript +// Re_Backend/src/controllers/dealerClaim.controller.ts + +async createClaimRequest(req: AuthenticatedRequest, res: Response) { + const userId = req.user?.userId; + const { + activityName, + activityType, + dealerCode, + // ... claim-specific fields + } = req.body; + + // 1. Create workflow request (common) + const workflowRequest = await WorkflowRequest.create({ + initiatorId: userId, + templateType: 'CUSTOM', + workflowType: 'CLAIM_MANAGEMENT', // Identify as claim + title: `${activityName} - Claim Request`, + description: req.body.requestDescription, + totalLevels: 8, // Fixed 8-step workflow + // ... other common fields + }); + + // 2. Create claim-specific details + const claimDetails = await DealerClaimDetails.create({ + requestId: workflowRequest.requestId, + activityName, + activityType, + dealerCode, + // ... claim-specific fields + }); + + // 3. Create approval levels (8 steps) + await this.createClaimApprovalLevels(workflowRequest.requestId); + + return ResponseHandler.success(res, { + request: workflowRequest, + claimDetails + }); +} +``` + +### 2. Get Request Details (Template-Aware) + +```typescript +async getRequestDetails(req: Request, res: Response) { + const { requestId } = req.params; + + const request = await WorkflowRequest.findByPk(requestId, { + include: [ + { model: User, as: 'initiator' }, + // Conditionally include template-specific data + ...(request.workflowType === 'CLAIM_MANAGEMENT' + ? [{ model: DealerClaimDetails, as: 'claimDetails' }] + : []) + ] + }); + + // Use factory to get template-specific service + const templateService = TemplateServiceFactory.getService(request.workflowType); + const enrichedDetails = await templateService.enrichRequestDetails(request); + + return ResponseHandler.success(res, enrichedDetails); +} +``` + +--- + +## Frontend Integration + +### 1. Request List Views (No Changes Needed) + +The existing "My Requests" and "Open Requests" pages will automatically show all request types because the backend doesn't filter by `workflow_type`. + +```typescript +// Frontend: MyRequests.tsx - No changes needed! +const fetchMyRequests = async () => { + const result = await workflowApi.listMyInitiatedWorkflows({ + page, + limit: itemsPerPage + }); + // Returns ALL request types automatically +}; +``` + +### 2. Request Detail Page (Template-Aware Rendering) + +```typescript +// Frontend: RequestDetail.tsx + +const RequestDetail = ({ requestId }) => { + const request = useRequestDetails(requestId); + + // Render based on workflow type + if (request.workflowType === 'CLAIM_MANAGEMENT') { + return ; + } else if (request.workflowType === 'NON_TEMPLATIZED') { + return ; + } else { + // Future templates - use generic renderer or template config + return ; + } +}; +``` + +--- + +## Adding New Templates (Future) + +### Step 1: Admin Creates Template in UI + +1. Admin goes to "Template Management" page +2. Creates new template with: + - Template name: "Vendor Payment" + - Template code: "VENDOR_PAYMENT" + - Approval levels configuration + - Form fields configuration + +### Step 2: Database Entry Created + +```sql +INSERT INTO workflow_templates ( + template_name, + template_code, + workflow_type, + approval_levels_config, + form_fields_config, + is_active, + is_system_template +) VALUES ( + 'Vendor Payment', + 'VENDOR_PAYMENT', + 'VENDOR_PAYMENT', + '{"levels": [...], "tat": {...}}'::jsonb, + '{"fields": [...]}'::jsonb, + true, + false -- Admin-created, not system template +); +``` + +### Step 3: Create Extension Table (If Needed) + +```sql +CREATE TABLE vendor_payment_details ( + payment_id UUID PRIMARY KEY, + request_id UUID UNIQUE REFERENCES workflow_requests(request_id), + vendor_code VARCHAR(50), + invoice_number VARCHAR(100), + payment_amount DECIMAL(15,2), + -- ... vendor-specific fields +); +``` + +### Step 4: Create Service (Optional - Can Use Generic Service) + +```typescript +// Re_Backend/src/services/vendorPayment.service.ts + +export class VendorPaymentService { + async getRequestDetails(request: WorkflowRequest) { + const paymentDetails = await VendorPaymentDetails.findOne({ + where: { requestId: request.requestId } + }); + + return { + ...request.toJSON(), + paymentDetails + }; + } +} + +// Update factory +TemplateServiceFactory.getService(workflowType: string) { + switch (workflowType) { + case 'VENDOR_PAYMENT': + return new VendorPaymentService(); + // ... existing cases + } +} +``` + +### Step 5: Frontend Component (Optional) + +```typescript +// Frontend: components/VendorPaymentDetail.tsx + +export function VendorPaymentDetail({ request }) { + // Render vendor payment specific UI +} +``` + +--- + +## Benefits of This Architecture + +1. **Unified Data Model**: All requests in one table, easy to query +2. **Automatic Inclusion**: My Requests/Open Requests show all types automatically +3. **Extensibility**: Add new templates without modifying existing code +4. **Type Safety**: Template-specific data in separate tables +5. **Flexibility**: Support both system templates and admin-created templates +6. **Backward Compatible**: Existing non-templatized requests continue to work + +--- + +## Migration Strategy + +1. **Phase 1**: Add `workflow_type` column, set default to 'NON_TEMPLATIZED' for existing requests +2. **Phase 2**: Create `dealer_claim_details` table and models +3. **Phase 3**: Update claim management creation flow to use new structure +4. **Phase 4**: Update request detail endpoints to be template-aware +5. **Phase 5**: Frontend updates (if needed) for template-specific rendering + +--- + +## Summary + +- **All requests** use `workflow_requests` table +- **Template identification** via `workflow_type` field +- **Template-specific data** in extension tables (e.g., `dealer_claim_details`) +- **Unified views** automatically include all types +- **Future templates** can be added by admins without code changes +- **Existing functionality** remains unchanged + +This architecture ensures that: +- ✅ Claim Management requests appear in My Requests/Open Requests +- ✅ Non-templatized requests continue to work +- ✅ Future templates can be added easily +- ✅ No code duplication +- ✅ Single source of truth for all requests + diff --git a/docs/IMPLEMENTATION_PROGRESS.md b/docs/IMPLEMENTATION_PROGRESS.md new file mode 100644 index 0000000..a04c073 --- /dev/null +++ b/docs/IMPLEMENTATION_PROGRESS.md @@ -0,0 +1,78 @@ +# Dealer Claim Management - Implementation Progress + +## ✅ Completed + +### 1. Database Migrations +- ✅ `20251210-add-workflow-type-support.ts` - Adds `workflow_type` and `template_id` to `workflow_requests` +- ✅ `20251210-enhance-workflow-templates.ts` - Enhances `workflow_templates` with form configuration fields +- ✅ `20251210-create-dealer-claim-tables.ts` - Creates dealer claim related tables: + - `dealer_claim_details` - Main claim information + - `dealer_proposal_details` - Step 1: Dealer proposal submission + - `dealer_completion_details` - Step 5: Dealer completion documents + +### 2. Models +- ✅ Updated `WorkflowRequest` model with `workflowType` and `templateId` fields +- ✅ Created `DealerClaimDetails` model +- ✅ Created `DealerProposalDetails` model +- ✅ Created `DealerCompletionDetails` model + +### 3. Services +- ✅ Created `TemplateFieldResolver` service for dynamic user field references + +## 🚧 In Progress + +### 4. Services (Next Steps) +- ⏳ Create `EnhancedTemplateService` - Main service for template operations +- ⏳ Create `DealerClaimService` - Claim-specific business logic + +### 5. Controllers & Routes +- ⏳ Create `DealerClaimController` - API endpoints for claim management +- ⏳ Create routes for dealer claim operations +- ⏳ Create template management endpoints + +## 📋 Next Steps + +1. **Create EnhancedTemplateService** + - Get form configuration with resolved user references + - Save step data + - Validate form data + +2. **Create DealerClaimService** + - Create claim request + - Handle 8-step workflow transitions + - Manage proposal and completion submissions + +3. **Create Controllers** + - POST `/api/v1/dealer-claims` - Create claim request + - GET `/api/v1/dealer-claims/:requestId` - Get claim details + - POST `/api/v1/dealer-claims/:requestId/proposal` - Submit proposal (Step 1) + - POST `/api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5) + - GET `/api/v1/templates/:templateId/form-config` - Get form configuration + +4. **Integration Services** + - SAP integration for IO validation and budget blocking + - DMS integration for e-invoice and credit note generation + +## 📝 Notes + +- All migrations are ready to run +- Models are created with proper associations +- Template field resolver supports dynamic user references +- System is designed to be extensible for future templates + +## 🔄 Running Migrations + +To apply the migrations: + +```bash +cd Re_Backend +npm run migrate +``` + +Or run individually: +```bash +npx ts-node src/scripts/run-migration.ts 20251210-add-workflow-type-support +npx ts-node src/scripts/run-migration.ts 20251210-enhance-workflow-templates +npx ts-node src/scripts/run-migration.ts 20251210-create-dealer-claim-tables +``` + diff --git a/docs/IMPLEMENTATION_SUMMARY.md b/docs/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..0b9217a --- /dev/null +++ b/docs/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,159 @@ +# Dealer Claim Management - Implementation Summary + +## ✅ Completed Implementation + +### 1. Database Migrations (4 files) +- ✅ `20251210-add-workflow-type-support.ts` - Adds `workflow_type` and `template_id` to `workflow_requests` +- ✅ `20251210-enhance-workflow-templates.ts` - Enhances `workflow_templates` with form configuration +- ✅ `20251210-add-template-id-foreign-key.ts` - Adds FK constraint for `template_id` +- ✅ `20251210-create-dealer-claim-tables.ts` - Creates dealer claim tables: + - `dealer_claim_details` - Main claim information + - `dealer_proposal_details` - Step 1: Dealer proposal + - `dealer_completion_details` - Step 5: Completion documents + +### 2. Models (5 files) +- ✅ Updated `WorkflowRequest` - Added `workflowType` and `templateId` fields +- ✅ Created `DealerClaimDetails` - Main claim information model +- ✅ Created `DealerProposalDetails` - Proposal submission model +- ✅ Created `DealerCompletionDetails` - Completion documents model +- ✅ Created `WorkflowTemplate` - Template configuration model + +### 3. Services (3 files) +- ✅ Created `TemplateFieldResolver` - Resolves dynamic user field references +- ✅ Created `EnhancedTemplateService` - Template form management +- ✅ Created `DealerClaimService` - Claim-specific business logic: + - `createClaimRequest()` - Create new claim with 8-step workflow + - `getClaimDetails()` - Get complete claim information + - `submitDealerProposal()` - Step 1: Dealer proposal submission + - `submitCompletionDocuments()` - Step 5: Completion submission + - `updateIODetails()` - Step 3: IO budget blocking + - `updateEInvoiceDetails()` - Step 7: E-Invoice generation + - `updateCreditNoteDetails()` - Step 8: Credit note issuance + +### 4. Controllers & Routes (2 files) +- ✅ Created `DealerClaimController` - API endpoints for claim operations +- ✅ Created `dealerClaim.routes.ts` - Route definitions +- ✅ Registered routes in `routes/index.ts` + +### 5. Frontend Utilities (1 file) +- ✅ Created `claimRequestUtils.ts` - Utility functions for detecting claim requests + +## 📋 API Endpoints Created + +### Dealer Claim Management +- `POST /api/v1/dealer-claims` - Create claim request +- `GET /api/v1/dealer-claims/:requestId` - Get claim details +- `POST /api/v1/dealer-claims/:requestId/proposal` - Submit dealer proposal (Step 1) +- `POST /api/v1/dealer-claims/:requestId/completion` - Submit completion (Step 5) +- `PUT /api/v1/dealer-claims/:requestId/io` - Update IO details (Step 3) +- `PUT /api/v1/dealer-claims/:requestId/e-invoice` - Update e-invoice (Step 7) +- `PUT /api/v1/dealer-claims/:requestId/credit-note` - Update credit note (Step 8) + +## 🔄 8-Step Workflow Implementation + +The system automatically creates 8 approval levels: + +1. **Dealer Proposal Submission** (72h) - Dealer submits proposal +2. **Requestor Evaluation** (48h) - Initiator reviews and confirms +3. **Department Lead Approval** (72h) - Dept lead approves and blocks IO +4. **Activity Creation** (1h, Auto) - System creates activity record +5. **Dealer Completion Documents** (120h) - Dealer submits completion docs +6. **Requestor Claim Approval** (48h) - Initiator approves claim +7. **E-Invoice Generation** (1h, Auto) - System generates e-invoice via DMS +8. **Credit Note Confirmation** (48h) - Finance confirms credit note + +## 🎯 Key Features + +1. **Unified Request System** + - All requests use same `workflow_requests` table + - Identified by `workflowType: 'CLAIM_MANAGEMENT'` + - Automatically appears in "My Requests" and "Open Requests" + +2. **Template-Specific Data Storage** + - Claim data stored in extension tables + - Linked via `request_id` foreign key + - Supports future templates with their own tables + +3. **Dynamic User References** + - Auto-populate fields from initiator, dealer, approvers + - Supports team lead, department lead references + - Configurable per template + +4. **File Upload Integration** + - Uses GCS with local fallback + - Organized by request number and file type + - Supports proposal documents and completion files + +## 📝 Next Steps + +### Backend +1. ⏳ Add SAP integration for IO validation and budget blocking +2. ⏳ Add DMS integration for e-invoice and credit note generation +3. ⏳ Create template management API endpoints +4. ⏳ Add validation for dealer codes (SAP integration) + +### Frontend +1. ⏳ Create `claimDataMapper.ts` utility functions +2. ⏳ Update `RequestDetail.tsx` to conditionally render claim components +3. ⏳ Update API services to include `workflowType` +4. ⏳ Create `dealerClaimApi.ts` service +5. ⏳ Update request cards to show workflow type + +## 🚀 Running the Implementation + +### 1. Run Migrations +```bash +cd Re_Backend +npm run migrate +``` + +### 2. Test API Endpoints +```bash +# Create claim request +POST /api/v1/dealer-claims +{ + "activityName": "Diwali Campaign", + "activityType": "Marketing Activity", + "dealerCode": "RE-MH-001", + "dealerName": "Royal Motors Mumbai", + "location": "Mumbai", + "requestDescription": "Marketing campaign details..." +} + +# Submit proposal +POST /api/v1/dealer-claims/:requestId/proposal +FormData with proposalDocument file and JSON data +``` + +## 📊 Database Structure + +``` +workflow_requests (common) + ├── workflow_type: 'CLAIM_MANAGEMENT' + └── template_id: (nullable) + +dealer_claim_details (claim-specific) + └── request_id → workflow_requests + +dealer_proposal_details (Step 1) + └── request_id → workflow_requests + +dealer_completion_details (Step 5) + └── request_id → workflow_requests + +approval_levels (8 steps) + └── request_id → workflow_requests +``` + +## ✅ Testing Checklist + +- [ ] Run migrations successfully +- [ ] Create claim request via API +- [ ] Submit dealer proposal +- [ ] Update IO details +- [ ] Submit completion documents +- [ ] Verify request appears in "My Requests" +- [ ] Verify request appears in "Open Requests" +- [ ] Test file uploads (GCS and local fallback) +- [ ] Test workflow progression through 8 steps + diff --git a/docs/OKTA_USERS_API_INTEGRATION.md b/docs/OKTA_USERS_API_INTEGRATION.md new file mode 100644 index 0000000..44f8071 --- /dev/null +++ b/docs/OKTA_USERS_API_INTEGRATION.md @@ -0,0 +1,167 @@ +# Okta Users API Integration + +## Overview + +The authentication service now uses the Okta Users API (`/api/v1/users/{userId}`) to fetch complete user profile information including manager, employeeID, designation, and other fields that may not be available in the standard OAuth2 userinfo endpoint. + +## Configuration + +Add the following environment variable to your `.env` file: + +```env +OKTA_API_TOKEN=your_okta_api_token_here +``` + +This is the SSWS (Server-Side Web Service) token for Okta API access. You can generate this token from your Okta Admin Console under **Security > API > Tokens**. + +## How It Works + +### 1. Primary Method: Okta Users API + +When a user logs in for the first time: + +1. The system exchanges the authorization code for tokens (OAuth2 flow) +2. Gets the `oktaSub` (subject identifier) from the userinfo endpoint +3. **Attempts to fetch full user profile from Users API** using: + - First: Email address (as shown in curl example) + - Fallback: oktaSub (user ID) if email lookup fails +4. Extracts complete user information including: + - `profile.employeeID` - Employee ID + - `profile.manager` - Manager name + - `profile.title` - Job title/designation + - `profile.department` - Department + - `profile.mobilePhone` - Phone number + - `profile.firstName`, `profile.lastName`, `profile.displayName` + - And other profile fields + +### 2. Fallback Method: OAuth2 Userinfo Endpoint + +If the Users API: +- Is not configured (missing `OKTA_API_TOKEN`) +- Returns an error (4xx/5xx) +- Fails for any reason + +The system automatically falls back to the standard OAuth2 userinfo endpoint (`/oauth2/default/v1/userinfo`) which provides basic user information. + +## API Endpoint + +``` +GET https://{oktaDomain}/api/v1/users/{userId} +Authorization: SSWS {OKTA_API_TOKEN} +Accept: application/json +``` + +Where `{userId}` can be: +- Email address (e.g., `testuser10@eichergroup.com`) +- Okta user ID (e.g., `00u1e1japegDV2DkP0h8`) + +## Response Structure + +The Users API returns a complete user object: + +```json +{ + "id": "00u1e1japegDV2DkP0h8", + "status": "ACTIVE", + "profile": { + "firstName": "Sanjay", + "lastName": "Sahu", + "manager": "Ezhilan subramanian", + "mobilePhone": "8826740087", + "displayName": "Sanjay Sahu", + "employeeID": "E09994", + "title": "Supports Business Applications (SAP) portfolio", + "department": "Deputy Manager - Digital & IT", + "login": "sanjaysahu@Royalenfield.com", + "email": "sanjaysahu@royalenfield.com" + }, + ... +} +``` + +## Field Mapping + +| Users API Field | Database Field | Notes | +|----------------|----------------|-------| +| `profile.employeeID` | `employeeId` | Employee ID from HR system | +| `profile.manager` | `manager` | Manager name | +| `profile.title` | `designation` | Job title/designation | +| `profile.department` | `department` | Department name | +| `profile.mobilePhone` | `phone` | Phone number | +| `profile.firstName` | `firstName` | First name | +| `profile.lastName` | `lastName` | Last name | +| `profile.displayName` | `displayName` | Display name | +| `profile.email` | `email` | Email address | +| `id` | `oktaSub` | Okta subject identifier | + +## Benefits + +1. **Complete User Profile**: Gets all available user information including manager, employeeID, and other custom attributes +2. **Automatic Fallback**: If Users API is unavailable, gracefully falls back to userinfo endpoint +3. **No Breaking Changes**: Existing functionality continues to work even without API token +4. **Better Data Quality**: Reduces missing user information (manager, employeeID, etc.) + +## Logging + +The service logs: +- When Users API is used vs. userinfo fallback +- Which lookup method succeeded (email or oktaSub) +- Extracted fields (employeeId, manager, department, etc.) +- Any errors or warnings + +Example log: +``` +[AuthService] Fetching user from Okta Users API (using email) +[AuthService] Successfully fetched user from Okta Users API (using email) +[AuthService] Extracted user data from Okta Users API + - oktaSub: 00u1e1japegDV2DkP0h8 + - email: testuser10@eichergroup.com + - employeeId: E09994 + - hasManager: true + - hasDepartment: true + - hasDesignation: true +``` + +## Testing + +### Test with curl + +```bash +curl --location 'https://dev-830839.oktapreview.com/api/v1/users/testuser10@eichergroup.com' \ + --header 'Authorization: SSWS YOUR_OKTA_API_TOKEN' \ + --header 'Accept: application/json' +``` + +### Test in Application + +1. Set `OKTA_API_TOKEN` in `.env` +2. Log in with a user +3. Check logs to see if Users API was used +4. Verify user record in database has complete information (manager, employeeID, etc.) + +## Troubleshooting + +### Users API Not Being Used + +- Check if `OKTA_API_TOKEN` is set in `.env` +- Check logs for warnings about missing API token +- Verify API token has correct permissions in Okta + +### Users API Returns 404 + +- User may not exist in Okta +- Email format may be incorrect +- Try using oktaSub (user ID) instead + +### Missing Fields in Database + +- Check if fields exist in Okta user profile +- Verify field mapping in `extractUserDataFromUsersAPI` method +- Check logs to see which fields were extracted + +## Security Notes + +- **API Token Security**: Store `OKTA_API_TOKEN` securely, never commit to version control +- **Token Permissions**: Ensure API token has read access to user profiles +- **Rate Limiting**: Be aware of Okta API rate limits when fetching user data + diff --git a/package.json b/package.json index 5ff91eb..bd375f1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "clean": "rm -rf dist", "setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts", "migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts", - "seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts" + "seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts", + "seed:dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-dealers.ts" }, "dependencies": { "@anthropic-ai/sdk": "^0.68.0", diff --git a/src/config/sso.ts b/src/config/sso.ts index a79e23d..56d1b96 100644 --- a/src/config/sso.ts +++ b/src/config/sso.ts @@ -11,6 +11,7 @@ const ssoConfig: SSOConfig = { oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com', oktaClientId: process.env.OKTA_CLIENT_ID || '', oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '', + oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API }; export { ssoConfig }; diff --git a/src/controllers/dealer.controller.ts b/src/controllers/dealer.controller.ts new file mode 100644 index 0000000..992e31d --- /dev/null +++ b/src/controllers/dealer.controller.ts @@ -0,0 +1,86 @@ +import { Request, Response } from 'express'; +import type { AuthenticatedRequest } from '../types/express'; +import * as dealerService from '../services/dealer.service'; +import { ResponseHandler } from '../utils/responseHandler'; +import logger from '../utils/logger'; + +export class DealerController { + /** + * Get all dealers + * GET /api/v1/dealers + */ + async getAllDealers(req: Request, res: Response): Promise { + try { + const dealers = await dealerService.getAllDealers(); + return ResponseHandler.success(res, dealers, 'Dealers fetched successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerController] Error fetching dealers:', error); + return ResponseHandler.error(res, 'Failed to fetch dealers', 500, errorMessage); + } + } + + /** + * Get dealer by code + * GET /api/v1/dealers/code/:dealerCode + */ + async getDealerByCode(req: Request, res: Response): Promise { + try { + const { dealerCode } = req.params; + const dealer = await dealerService.getDealerByCode(dealerCode); + + if (!dealer) { + return ResponseHandler.error(res, 'Dealer not found', 404); + } + + return ResponseHandler.success(res, dealer, 'Dealer fetched successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerController] Error fetching dealer by code:', error); + return ResponseHandler.error(res, 'Failed to fetch dealer', 500, errorMessage); + } + } + + /** + * Get dealer by email + * GET /api/v1/dealers/email/:email + */ + async getDealerByEmail(req: Request, res: Response): Promise { + try { + const { email } = req.params; + const dealer = await dealerService.getDealerByEmail(email); + + if (!dealer) { + return ResponseHandler.error(res, 'Dealer not found', 404); + } + + return ResponseHandler.success(res, dealer, 'Dealer fetched successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerController] Error fetching dealer by email:', error); + return ResponseHandler.error(res, 'Failed to fetch dealer', 500, errorMessage); + } + } + + /** + * Search dealers + * GET /api/v1/dealers/search?q=searchTerm + */ + async searchDealers(req: Request, res: Response): Promise { + try { + const { q } = req.query; + + if (!q || typeof q !== 'string') { + return ResponseHandler.error(res, 'Search term is required', 400); + } + + const dealers = await dealerService.searchDealers(q); + return ResponseHandler.success(res, dealers, 'Dealers searched successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerController] Error searching dealers:', error); + return ResponseHandler.error(res, 'Failed to search dealers', 500, errorMessage); + } + } +} + diff --git a/src/controllers/dealerClaim.controller.ts b/src/controllers/dealerClaim.controller.ts new file mode 100644 index 0000000..d7a19d3 --- /dev/null +++ b/src/controllers/dealerClaim.controller.ts @@ -0,0 +1,418 @@ +import { Request, Response } from 'express'; +import type { AuthenticatedRequest } from '../types/express'; +import { DealerClaimService } from '../services/dealerClaim.service'; +import { ResponseHandler } from '../utils/responseHandler'; +import logger from '../utils/logger'; +import { gcsStorageService } from '../services/gcsStorage.service'; +import fs from 'fs'; +import path from 'path'; + +export class DealerClaimController { + private dealerClaimService = new DealerClaimService(); + + /** + * Create a new dealer claim request + * POST /api/v1/dealer-claims + */ + async createClaimRequest(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user?.userId; + if (!userId) { + return ResponseHandler.error(res, 'Unauthorized', 401); + } + + const { + activityName, + activityType, + dealerCode, + dealerName, + dealerEmail, + dealerPhone, + dealerAddress, + activityDate, + location, + requestDescription, + periodStartDate, + periodEndDate, + estimatedBudget, + } = req.body; + + // Validation + if (!activityName || !activityType || !dealerCode || !dealerName || !location || !requestDescription) { + return ResponseHandler.error(res, 'Missing required fields', 400); + } + + const claimRequest = await this.dealerClaimService.createClaimRequest(userId, { + activityName, + activityType, + dealerCode, + dealerName, + dealerEmail, + dealerPhone, + dealerAddress, + activityDate: activityDate ? new Date(activityDate) : undefined, + location, + requestDescription, + periodStartDate: periodStartDate ? new Date(periodStartDate) : undefined, + periodEndDate: periodEndDate ? new Date(periodEndDate) : undefined, + estimatedBudget: estimatedBudget ? parseFloat(estimatedBudget) : undefined, + }); + + return ResponseHandler.success(res, { + request: claimRequest, + message: 'Claim request created successfully' + }, 'Claim request created'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error creating claim request:', error); + return ResponseHandler.error(res, 'Failed to create claim request', 500, errorMessage); + } + } + + /** + * Get claim details + * GET /api/v1/dealer-claims/:requestId + * Accepts either UUID or requestNumber + */ + async getClaimDetails(req: Request, res: Response): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + + // Find workflow to get actual UUID + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + const requestId = (workflow as any).requestId || (workflow as any).request_id; + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + const claimDetails = await this.dealerClaimService.getClaimDetails(requestId); + + return ResponseHandler.success(res, claimDetails, 'Claim details fetched'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error getting claim details:', error); + return ResponseHandler.error(res, 'Failed to fetch claim details', 500, errorMessage); + } + } + + /** + * Helper to find workflow by either requestId (UUID) or requestNumber + */ + private async findWorkflowByIdentifier(identifier: string): Promise { + const isUuid = (id: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(id); + }; + + const { WorkflowRequest } = await import('../models/WorkflowRequest'); + if (isUuid(identifier)) { + return await WorkflowRequest.findByPk(identifier); + } else { + return await WorkflowRequest.findOne({ where: { requestNumber: identifier } }); + } + } + + /** + * Submit dealer proposal (Step 1) + * POST /api/v1/dealer-claims/:requestId/proposal + * Accepts either UUID or requestNumber + */ + async submitProposal(req: AuthenticatedRequest, res: Response): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + const userId = req.user?.userId; + const { + costBreakup, + totalEstimatedBudget, + timelineMode, + expectedCompletionDate, + expectedCompletionDays, + dealerComments, + } = req.body; + + // Find workflow by identifier (UUID or requestNumber) + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + // Get actual UUID and requestNumber + const requestId = (workflow as any).requestId || (workflow as any).request_id; + const requestNumber = (workflow as any).requestNumber || (workflow as any).request_number; + + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + // Parse costBreakup - it comes as JSON string from FormData + let parsedCostBreakup: any[] = []; + if (costBreakup) { + if (typeof costBreakup === 'string') { + try { + parsedCostBreakup = JSON.parse(costBreakup); + } catch (parseError) { + logger.error('[DealerClaimController] Failed to parse costBreakup JSON:', parseError); + return ResponseHandler.error(res, 'Invalid costBreakup format. Expected JSON array.', 400); + } + } else if (Array.isArray(costBreakup)) { + parsedCostBreakup = costBreakup; + } else { + logger.warn('[DealerClaimController] costBreakup is not a string or array:', typeof costBreakup); + parsedCostBreakup = []; + } + } + + // Validate costBreakup is an array + if (!Array.isArray(parsedCostBreakup)) { + logger.error('[DealerClaimController] costBreakup is not an array after parsing:', parsedCostBreakup); + return ResponseHandler.error(res, 'costBreakup must be an array of cost items', 400); + } + + // Validate each cost item has required fields + for (const item of parsedCostBreakup) { + if (!item.description || item.amount === undefined || item.amount === null) { + return ResponseHandler.error(res, 'Each cost item must have description and amount', 400); + } + } + + // Handle file upload if present + let proposalDocumentPath: string | undefined; + let proposalDocumentUrl: string | undefined; + + if (req.file) { + const file = req.file; + const fileBuffer = file.buffer || (file.path ? fs.readFileSync(file.path) : Buffer.from('')); + + const uploadResult = await gcsStorageService.uploadFileWithFallback({ + buffer: fileBuffer, + originalName: file.originalname, + mimeType: file.mimetype, + requestNumber: requestNumber || 'UNKNOWN', + fileType: 'documents' + }); + + proposalDocumentPath = uploadResult.filePath; + proposalDocumentUrl = uploadResult.storageUrl; + + // Cleanup local file if exists + if (file.path && fs.existsSync(file.path)) { + fs.unlinkSync(file.path); + } + } + + // Use actual UUID for service call with parsed costBreakup array + await this.dealerClaimService.submitDealerProposal(requestId, { + proposalDocumentPath, + proposalDocumentUrl, + costBreakup: parsedCostBreakup, // Use parsed array + totalEstimatedBudget: totalEstimatedBudget ? parseFloat(totalEstimatedBudget) : 0, + timelineMode: timelineMode || 'date', + expectedCompletionDate: expectedCompletionDate ? new Date(expectedCompletionDate) : undefined, + expectedCompletionDays: expectedCompletionDays ? parseInt(expectedCompletionDays) : undefined, + dealerComments: dealerComments || '', + }); + + return ResponseHandler.success(res, { message: 'Proposal submitted successfully' }, 'Proposal submitted'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error submitting proposal:', error); + return ResponseHandler.error(res, 'Failed to submit proposal', 500, errorMessage); + } + } + + /** + * Submit completion documents (Step 5) + * POST /api/v1/dealer-claims/:requestId/completion + * Accepts either UUID or requestNumber + */ + async submitCompletion(req: AuthenticatedRequest, res: Response): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + const { + activityCompletionDate, + numberOfParticipants, + closedExpenses, + totalClosedExpenses, + completionDocuments, + activityPhotos, + } = req.body; + + // Find workflow to get actual UUID + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + const requestId = (workflow as any).requestId || (workflow as any).request_id; + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + if (!activityCompletionDate) { + return ResponseHandler.error(res, 'Activity completion date is required', 400); + } + + await this.dealerClaimService.submitCompletionDocuments(requestId, { + activityCompletionDate: new Date(activityCompletionDate), + numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined, + closedExpenses: closedExpenses || [], + totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0, + completionDocuments: completionDocuments || [], + activityPhotos: activityPhotos || [], + }); + + return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error submitting completion:', error); + return ResponseHandler.error(res, 'Failed to submit completion documents', 500, errorMessage); + } + } + + /** + * Update IO details (Step 3 - Department Lead) + * PUT /api/v1/dealer-claims/:requestId/io + * Accepts either UUID or requestNumber + */ + async updateIODetails(req: AuthenticatedRequest, res: Response): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + const { + ioNumber, + availableBalance, + blockedAmount, + remainingBalance, + } = req.body; + + // Find workflow to get actual UUID + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + const requestId = (workflow as any).requestId || (workflow as any).request_id; + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + if (!ioNumber || availableBalance === undefined || blockedAmount === undefined) { + return ResponseHandler.error(res, 'Missing required IO fields', 400); + } + + await this.dealerClaimService.updateIODetails(requestId, { + ioNumber, + availableBalance: parseFloat(availableBalance), + blockedAmount: parseFloat(blockedAmount), + remainingBalance: remainingBalance ? parseFloat(remainingBalance) : parseFloat(availableBalance) - parseFloat(blockedAmount), + }); + + return ResponseHandler.success(res, { message: 'IO details updated successfully' }, 'IO details updated'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error updating IO details:', error); + return ResponseHandler.error(res, 'Failed to update IO details', 500, errorMessage); + } + } + + /** + * Update e-invoice details (Step 7) + * PUT /api/v1/dealer-claims/:requestId/e-invoice + * If eInvoiceNumber is not provided, will auto-generate via DMS + * Accepts either UUID or requestNumber + */ + async updateEInvoice(req: AuthenticatedRequest, res: Response): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + const { + eInvoiceNumber, + eInvoiceDate, + dmsNumber, + amount, + description, + } = req.body; + + // Find workflow to get actual UUID + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + const requestId = (workflow as any).requestId || (workflow as any).request_id; + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + // If eInvoiceNumber provided, use manual entry; otherwise auto-generate + const invoiceData = eInvoiceNumber ? { + eInvoiceNumber, + eInvoiceDate: eInvoiceDate ? new Date(eInvoiceDate) : new Date(), + dmsNumber, + } : { + amount: amount ? parseFloat(amount) : undefined, + description, + }; + + await this.dealerClaimService.updateEInvoiceDetails(requestId, invoiceData); + + return ResponseHandler.success(res, { message: 'E-Invoice details updated successfully' }, 'E-Invoice updated'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error updating e-invoice:', error); + return ResponseHandler.error(res, 'Failed to update e-invoice details', 500, errorMessage); + } + } + + /** + * Update credit note details (Step 8) + * PUT /api/v1/dealer-claims/:requestId/credit-note + * If creditNoteNumber is not provided, will auto-generate via DMS + * Accepts either UUID or requestNumber + */ + async updateCreditNote(req: AuthenticatedRequest, res: Response): Promise { + try { + const identifier = req.params.requestId; // Can be UUID or requestNumber + const { + creditNoteNumber, + creditNoteDate, + creditNoteAmount, + reason, + description, + } = req.body; + + // Find workflow to get actual UUID + const workflow = await this.findWorkflowByIdentifier(identifier); + if (!workflow) { + return ResponseHandler.error(res, 'Workflow request not found', 404); + } + + const requestId = (workflow as any).requestId || (workflow as any).request_id; + if (!requestId) { + return ResponseHandler.error(res, 'Invalid workflow request', 400); + } + + // If creditNoteNumber provided, use manual entry; otherwise auto-generate + const creditNoteData = creditNoteNumber ? { + creditNoteNumber, + creditNoteDate: creditNoteDate ? new Date(creditNoteDate) : new Date(), + creditNoteAmount: creditNoteAmount ? parseFloat(creditNoteAmount) : undefined, + } : { + creditNoteAmount: creditNoteAmount ? parseFloat(creditNoteAmount) : undefined, + reason, + description, + }; + + await this.dealerClaimService.updateCreditNoteDetails(requestId, creditNoteData); + + return ResponseHandler.success(res, { message: 'Credit note details updated successfully' }, 'Credit note updated'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[DealerClaimController] Error updating credit note:', error); + return ResponseHandler.error(res, 'Failed to update credit note details', 500, errorMessage); + } + } +} + diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index 640dd24..b405beb 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -22,19 +22,56 @@ export class DocumentController { return; } - const requestId = String((req.body?.requestId || '').trim()); - if (!requestId) { + // Extract requestId from body (multer should parse form fields) + // Try both req.body and req.body.requestId for compatibility + const identifier = String((req.body?.requestId || req.body?.request_id || '').trim()); + if (!identifier || identifier === 'undefined' || identifier === 'null') { + logWithContext('error', 'RequestId missing or invalid in document upload', { + body: req.body, + bodyKeys: Object.keys(req.body || {}), + userId: req.user?.userId + }); ResponseHandler.error(res, 'requestId is required', 400); return; } - // Get workflow request to retrieve requestNumber - const workflowRequest = await WorkflowRequest.findOne({ where: { requestId } }); + // Helper to check if identifier is UUID + const isUuid = (id: string): boolean => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(id); + }; + + // Get workflow request - handle both UUID (requestId) and requestNumber + let workflowRequest: WorkflowRequest | null = null; + if (isUuid(identifier)) { + workflowRequest = await WorkflowRequest.findByPk(identifier); + } else { + workflowRequest = await WorkflowRequest.findOne({ where: { requestNumber: identifier } }); + } + if (!workflowRequest) { + logWithContext('error', 'Workflow request not found for document upload', { + identifier, + isUuid: isUuid(identifier), + userId: req.user?.userId + }); ResponseHandler.error(res, 'Workflow request not found', 404); return; } + + // Get the actual requestId (UUID) and requestNumber + const requestId = (workflowRequest as any).requestId || (workflowRequest as any).request_id; const requestNumber = (workflowRequest as any).requestNumber || (workflowRequest as any).request_number; + + if (!requestNumber) { + logWithContext('error', 'Request number not found for workflow', { + requestId, + workflowRequest: JSON.stringify(workflowRequest.toJSON()), + userId: req.user?.userId + }); + ResponseHandler.error(res, 'Request number not found for workflow', 500); + return; + } const file = (req as any).file as Express.Multer.File | undefined; if (!file) { @@ -153,10 +190,21 @@ export class DocumentController { ResponseHandler.success(res, doc, 'File uploaded', 201); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; logWithContext('error', 'Document upload failed', { userId: req.user?.userId, - requestId: req.body?.requestId, - error, + requestId: req.body?.requestId || req.body?.request_id, + body: req.body, + bodyKeys: Object.keys(req.body || {}), + file: req.file ? { + originalname: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + hasBuffer: !!req.file.buffer, + hasPath: !!req.file.path + } : 'No file', + error: message, + stack: errorStack }); ResponseHandler.error(res, 'Upload failed', 500, message); } diff --git a/src/controllers/template.controller.ts b/src/controllers/template.controller.ts new file mode 100644 index 0000000..99c2ee7 --- /dev/null +++ b/src/controllers/template.controller.ts @@ -0,0 +1,192 @@ +import { Request, Response } from 'express'; +import type { AuthenticatedRequest } from '../types/express'; +import { TemplateService } from '../services/template.service'; +import { ResponseHandler } from '../utils/responseHandler'; +import logger from '../utils/logger'; + +export class TemplateController { + private templateService = new TemplateService(); + + /** + * Create a new template + * POST /api/v1/templates + */ + async createTemplate(req: AuthenticatedRequest, res: Response): Promise { + try { + const userId = req.user?.userId; + if (!userId) { + return ResponseHandler.error(res, 'Unauthorized', 401); + } + + const { + templateName, + templateCode, + templateDescription, + templateCategory, + workflowType, + approvalLevelsConfig, + defaultTatHours, + formStepsConfig, + userFieldMappings, + dynamicApproverConfig, + isActive, + } = req.body; + + if (!templateName) { + return ResponseHandler.error(res, 'Template name is required', 400); + } + + const template = await this.templateService.createTemplate(userId, { + templateName, + templateCode, + templateDescription, + templateCategory, + workflowType, + approvalLevelsConfig, + defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined, + formStepsConfig, + userFieldMappings, + dynamicApproverConfig, + isActive, + }); + + return ResponseHandler.success(res, template, 'Template created successfully', 201); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[TemplateController] Error creating template:', error); + return ResponseHandler.error(res, 'Failed to create template', 500, errorMessage); + } + } + + /** + * Get template by ID + * GET /api/v1/templates/:templateId + */ + async getTemplate(req: Request, res: Response): Promise { + try { + const { templateId } = req.params; + + const template = await this.templateService.getTemplate(templateId); + + if (!template) { + return ResponseHandler.error(res, 'Template not found', 404); + } + + return ResponseHandler.success(res, template, 'Template fetched'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[TemplateController] Error getting template:', error); + return ResponseHandler.error(res, 'Failed to fetch template', 500, errorMessage); + } + } + + /** + * List templates + * GET /api/v1/templates + */ + async listTemplates(req: Request, res: Response): Promise { + try { + const { + category, + workflowType, + isActive, + isSystemTemplate, + search, + } = req.query; + + const filters: any = {}; + if (category) filters.category = category as string; + if (workflowType) filters.workflowType = workflowType as string; + if (isActive !== undefined) filters.isActive = isActive === 'true'; + if (isSystemTemplate !== undefined) filters.isSystemTemplate = isSystemTemplate === 'true'; + if (search) filters.search = search as string; + + const templates = await this.templateService.listTemplates(filters); + + return ResponseHandler.success(res, templates, 'Templates fetched'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[TemplateController] Error listing templates:', error); + return ResponseHandler.error(res, 'Failed to fetch templates', 500, errorMessage); + } + } + + /** + * Get active templates (for workflow creation) + * GET /api/v1/templates/active + */ + async getActiveTemplates(req: Request, res: Response): Promise { + try { + const templates = await this.templateService.getActiveTemplates(); + + return ResponseHandler.success(res, templates, 'Active templates fetched'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[TemplateController] Error getting active templates:', error); + return ResponseHandler.error(res, 'Failed to fetch active templates', 500, errorMessage); + } + } + + /** + * Update template + * PUT /api/v1/templates/:templateId + */ + async updateTemplate(req: AuthenticatedRequest, res: Response): Promise { + try { + const { templateId } = req.params; + const userId = req.user?.userId; + if (!userId) { + return ResponseHandler.error(res, 'Unauthorized', 401); + } + + const { + templateName, + templateDescription, + templateCategory, + approvalLevelsConfig, + defaultTatHours, + formStepsConfig, + userFieldMappings, + dynamicApproverConfig, + isActive, + } = req.body; + + const template = await this.templateService.updateTemplate(templateId, userId, { + templateName, + templateDescription, + templateCategory, + approvalLevelsConfig, + defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined, + formStepsConfig, + userFieldMappings, + dynamicApproverConfig, + isActive, + }); + + return ResponseHandler.success(res, template, 'Template updated successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[TemplateController] Error updating template:', error); + return ResponseHandler.error(res, 'Failed to update template', 500, errorMessage); + } + } + + /** + * Delete template + * DELETE /api/v1/templates/:templateId + */ + async deleteTemplate(req: AuthenticatedRequest, res: Response): Promise { + try { + const { templateId } = req.params; + + await this.templateService.deleteTemplate(templateId); + + return ResponseHandler.success(res, { message: 'Template deleted successfully' }, 'Template deleted'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[TemplateController] Error deleting template:', error); + return ResponseHandler.error(res, 'Failed to delete template', 500, errorMessage); + } + } +} + diff --git a/src/migrations/20251210-add-template-id-foreign-key.ts b/src/migrations/20251210-add-template-id-foreign-key.ts new file mode 100644 index 0000000..0d777e1 --- /dev/null +++ b/src/migrations/20251210-add-template-id-foreign-key.ts @@ -0,0 +1,54 @@ +import { QueryInterface } from 'sequelize'; + +/** + * Add foreign key constraint for template_id after workflow_templates table exists + * This should run after both: + * - 20251210-enhance-workflow-templates (creates workflow_templates table) + * - 20251210-add-workflow-type-support (adds template_id column) + */ +export async function up(queryInterface: QueryInterface): Promise { + // Check if workflow_templates table exists + const [tables] = await queryInterface.sequelize.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'workflow_templates'; + `); + + if (tables.length > 0) { + // Check if foreign key already exists + const [constraints] = await queryInterface.sequelize.query(` + SELECT constraint_name + FROM information_schema.table_constraints + WHERE table_schema = 'public' + AND table_name = 'workflow_requests' + AND constraint_name = 'workflow_requests_template_id_fkey'; + `); + + if (constraints.length === 0) { + // Add foreign key constraint + await queryInterface.sequelize.query(` + ALTER TABLE workflow_requests + ADD CONSTRAINT workflow_requests_template_id_fkey + FOREIGN KEY (template_id) + REFERENCES workflow_templates(template_id) + ON UPDATE CASCADE + ON DELETE SET NULL; + `); + } + } +} + +export async function down(queryInterface: QueryInterface): Promise { + // Remove foreign key constraint if it exists + try { + await queryInterface.sequelize.query(` + ALTER TABLE workflow_requests + DROP CONSTRAINT IF EXISTS workflow_requests_template_id_fkey; + `); + } catch (error) { + // Ignore if constraint doesn't exist + console.log('Note: Foreign key constraint may not exist'); + } +} + diff --git a/src/migrations/20251210-add-workflow-type-support.ts b/src/migrations/20251210-add-workflow-type-support.ts new file mode 100644 index 0000000..e1bf967 --- /dev/null +++ b/src/migrations/20251210-add-workflow-type-support.ts @@ -0,0 +1,116 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + try { + // Check if columns already exist (for idempotency and backward compatibility) + const tableDescription = await queryInterface.describeTable('workflow_requests'); + + // 1. Add workflow_type column to workflow_requests (only if it doesn't exist) + if (!tableDescription.workflow_type) { + try { + await queryInterface.addColumn('workflow_requests', 'workflow_type', { + type: DataTypes.STRING(50), + allowNull: true, + defaultValue: 'NON_TEMPLATIZED' + }); + console.log('✅ Added workflow_type column'); + } catch (error: any) { + // Column might have been added manually, check if it exists now + const updatedDescription = await queryInterface.describeTable('workflow_requests'); + if (!updatedDescription.workflow_type) { + throw error; // Re-throw if column still doesn't exist + } + console.log('Note: workflow_type column already exists (may have been added manually)'); + } + } else { + console.log('Note: workflow_type column already exists, skipping'); + } + + // 2. Add template_id column (nullable, for admin templates) + // Note: Foreign key constraint will be added later if workflow_templates table exists + if (!tableDescription.template_id) { + try { + await queryInterface.addColumn('workflow_requests', 'template_id', { + type: DataTypes.UUID, + allowNull: true + }); + console.log('✅ Added template_id column'); + } catch (error: any) { + // Column might have been added manually, check if it exists now + const updatedDescription = await queryInterface.describeTable('workflow_requests'); + if (!updatedDescription.template_id) { + throw error; // Re-throw if column still doesn't exist + } + console.log('Note: template_id column already exists (may have been added manually)'); + } + } else { + console.log('Note: template_id column already exists, skipping'); + } + + // Get updated table description for index creation + const finalTableDescription = await queryInterface.describeTable('workflow_requests'); + + // 3. Create index for workflow_type (only if column exists) + if (finalTableDescription.workflow_type) { + try { + await queryInterface.addIndex('workflow_requests', ['workflow_type'], { + name: 'idx_workflow_requests_workflow_type' + }); + console.log('✅ Created workflow_type index'); + } catch (error: any) { + // Index might already exist, ignore error + if (error.message?.includes('already exists') || error.message?.includes('duplicate')) { + console.log('Note: workflow_type index already exists'); + } else { + console.log('Note: Could not create workflow_type index:', error.message); + } + } + } + + // 4. Create index for template_id (only if column exists) + if (finalTableDescription.template_id) { + try { + await queryInterface.addIndex('workflow_requests', ['template_id'], { + name: 'idx_workflow_requests_template_id' + }); + console.log('✅ Created template_id index'); + } catch (error: any) { + // Index might already exist, ignore error + if (error.message?.includes('already exists') || error.message?.includes('duplicate')) { + console.log('Note: template_id index already exists'); + } else { + console.log('Note: Could not create template_id index:', error.message); + } + } + } + + // 5. Update existing records to have workflow_type (if any exist and column exists) + if (finalTableDescription.workflow_type) { + try { + const [result] = await queryInterface.sequelize.query(` + UPDATE workflow_requests + SET workflow_type = 'NON_TEMPLATIZED' + WHERE workflow_type IS NULL; + `); + console.log('✅ Updated existing records with workflow_type'); + } catch (error: any) { + // Ignore if table is empty or other error + console.log('Note: Could not update existing records:', error.message); + } + } + } catch (error: any) { + console.error('Migration error:', error.message); + throw error; + } +} + +export async function down(queryInterface: QueryInterface): Promise { + // Remove indexes + await queryInterface.removeIndex('workflow_requests', 'idx_workflow_requests_template_id'); + await queryInterface.removeIndex('workflow_requests', 'idx_workflow_requests_workflow_type'); + + // Remove columns + await queryInterface.removeColumn('workflow_requests', 'template_id'); + await queryInterface.removeColumn('workflow_requests', 'workflow_type'); +} + diff --git a/src/migrations/20251210-create-dealer-claim-tables.ts b/src/migrations/20251210-create-dealer-claim-tables.ts new file mode 100644 index 0000000..4ac5d52 --- /dev/null +++ b/src/migrations/20251210-create-dealer-claim-tables.ts @@ -0,0 +1,282 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + // 1. Create dealer_claim_details table + await queryInterface.createTable('dealer_claim_details', { + claim_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + references: { + model: 'workflow_requests', + key: 'request_id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + activity_name: { + type: DataTypes.STRING(500), + allowNull: false + }, + activity_type: { + type: DataTypes.STRING(100), + allowNull: false + }, + dealer_code: { + type: DataTypes.STRING(50), + allowNull: false + }, + dealer_name: { + type: DataTypes.STRING(200), + allowNull: false + }, + dealer_email: { + type: DataTypes.STRING(255), + allowNull: true + }, + dealer_phone: { + type: DataTypes.STRING(20), + allowNull: true + }, + dealer_address: { + type: DataTypes.TEXT, + allowNull: true + }, + activity_date: { + type: DataTypes.DATEONLY, + allowNull: true + }, + location: { + type: DataTypes.STRING(255), + allowNull: true + }, + period_start_date: { + type: DataTypes.DATEONLY, + allowNull: true + }, + period_end_date: { + type: DataTypes.DATEONLY, + allowNull: true + }, + estimated_budget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + closed_expenses: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + io_number: { + type: DataTypes.STRING(50), + allowNull: true + }, + io_available_balance: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + io_blocked_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + io_remaining_balance: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + sap_document_number: { + type: DataTypes.STRING(100), + allowNull: true + }, + dms_number: { + type: DataTypes.STRING(100), + allowNull: true + }, + e_invoice_number: { + type: DataTypes.STRING(100), + allowNull: true + }, + e_invoice_date: { + type: DataTypes.DATEONLY, + allowNull: true + }, + credit_note_number: { + type: DataTypes.STRING(100), + allowNull: true + }, + credit_note_date: { + type: DataTypes.DATEONLY, + allowNull: true + }, + credit_note_amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + // Create indexes + await queryInterface.addIndex('dealer_claim_details', ['request_id'], { + name: 'idx_dealer_claim_details_request_id', + unique: true + }); + await queryInterface.addIndex('dealer_claim_details', ['dealer_code'], { + name: 'idx_dealer_claim_details_dealer_code' + }); + await queryInterface.addIndex('dealer_claim_details', ['activity_type'], { + name: 'idx_dealer_claim_details_activity_type' + }); + + // 2. Create dealer_proposal_details table (Step 1: Dealer Proposal) + await queryInterface.createTable('dealer_proposal_details', { + proposal_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + references: { + model: 'workflow_requests', + key: 'request_id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + proposal_document_path: { + type: DataTypes.STRING(500), + allowNull: true + }, + proposal_document_url: { + type: DataTypes.STRING(500), + allowNull: true + }, + cost_breakup: { + type: DataTypes.JSONB, + allowNull: true + }, + total_estimated_budget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + timeline_mode: { + type: DataTypes.STRING(10), + allowNull: true + }, + expected_completion_date: { + type: DataTypes.DATEONLY, + allowNull: true + }, + expected_completion_days: { + type: DataTypes.INTEGER, + allowNull: true + }, + dealer_comments: { + type: DataTypes.TEXT, + allowNull: true + }, + submitted_at: { + type: DataTypes.DATE, + allowNull: true + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + await queryInterface.addIndex('dealer_proposal_details', ['request_id'], { + name: 'idx_dealer_proposal_details_request_id', + unique: true + }); + + // 3. Create dealer_completion_details table (Step 5: Dealer Completion) + await queryInterface.createTable('dealer_completion_details', { + completion_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + references: { + model: 'workflow_requests', + key: 'request_id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + activity_completion_date: { + type: DataTypes.DATEONLY, + allowNull: false + }, + number_of_participants: { + type: DataTypes.INTEGER, + allowNull: true + }, + closed_expenses: { + type: DataTypes.JSONB, + allowNull: true + }, + total_closed_expenses: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true + }, + completion_documents: { + type: DataTypes.JSONB, + allowNull: true + }, + activity_photos: { + type: DataTypes.JSONB, + allowNull: true + }, + submitted_at: { + type: DataTypes.DATE, + allowNull: true + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + await queryInterface.addIndex('dealer_completion_details', ['request_id'], { + name: 'idx_dealer_completion_details_request_id', + unique: true + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.dropTable('dealer_completion_details'); + await queryInterface.dropTable('dealer_proposal_details'); + await queryInterface.dropTable('dealer_claim_details'); +} + diff --git a/src/migrations/20251210-create-proposal-cost-items-table.ts b/src/migrations/20251210-create-proposal-cost-items-table.ts new file mode 100644 index 0000000..9e33c50 --- /dev/null +++ b/src/migrations/20251210-create-proposal-cost-items-table.ts @@ -0,0 +1,194 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +/** + * Migration: Create dealer_proposal_cost_items table + * + * Purpose: Separate table for cost breakups to enable better querying, reporting, and data integrity + * This replaces the JSONB costBreakup field in dealer_proposal_details + * + * Benefits: + * - Better querying and filtering + * - Easier to update individual cost items + * - Better for analytics and reporting + * - Maintains referential integrity + */ +export async function up(queryInterface: QueryInterface): Promise { + // Check if table already exists + const [tables] = await queryInterface.sequelize.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'dealer_proposal_cost_items'; + `); + + if (tables.length === 0) { + // Create dealer_proposal_cost_items table + await queryInterface.createTable('dealer_proposal_cost_items', { + cost_item_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + field: 'cost_item_id' + }, + proposal_id: { + type: DataTypes.UUID, + allowNull: false, + field: 'proposal_id', + references: { + model: 'dealer_proposal_details', + key: 'proposal_id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE' + }, + request_id: { + type: DataTypes.UUID, + allowNull: false, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + comment: 'Denormalized for easier querying without joins' + }, + item_description: { + type: DataTypes.STRING(500), + allowNull: false, + field: 'item_description' + }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false, + field: 'amount', + comment: 'Cost amount in INR' + }, + item_order: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'item_order', + comment: 'Order of item in the cost breakdown list' + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }); + + // Create indexes for better query performance + await queryInterface.addIndex('dealer_proposal_cost_items', ['proposal_id'], { + name: 'idx_proposal_cost_items_proposal_id' + }); + + await queryInterface.addIndex('dealer_proposal_cost_items', ['request_id'], { + name: 'idx_proposal_cost_items_request_id' + }); + + await queryInterface.addIndex('dealer_proposal_cost_items', ['proposal_id', 'item_order'], { + name: 'idx_proposal_cost_items_proposal_order' + }); + + console.log('✅ Created dealer_proposal_cost_items table'); + } else { + console.log('Note: dealer_proposal_cost_items table already exists'); + } + + // Migrate existing JSONB costBreakup data to the new table + try { + const [existingProposals] = await queryInterface.sequelize.query(` + SELECT proposal_id, request_id, cost_breakup + FROM dealer_proposal_details + WHERE cost_breakup IS NOT NULL + AND cost_breakup::text != 'null' + AND cost_breakup::text != '[]'; + `); + + if (Array.isArray(existingProposals) && existingProposals.length > 0) { + console.log(`📦 Migrating ${existingProposals.length} existing proposal(s) with cost breakups...`); + + for (const proposal of existingProposals as any[]) { + const proposalId = proposal.proposal_id; + const requestId = proposal.request_id; + let costBreakup = proposal.cost_breakup; + + // Parse JSONB if it's a string + if (typeof costBreakup === 'string') { + try { + costBreakup = JSON.parse(costBreakup); + } catch (e) { + console.warn(`⚠️ Failed to parse costBreakup for proposal ${proposalId}:`, e); + continue; + } + } + + // Ensure it's an array + if (!Array.isArray(costBreakup)) { + console.warn(`⚠️ costBreakup is not an array for proposal ${proposalId}`); + continue; + } + + // Insert cost items + for (let i = 0; i < costBreakup.length; i++) { + const item = costBreakup[i]; + if (item && item.description && item.amount !== undefined) { + await queryInterface.sequelize.query(` + INSERT INTO dealer_proposal_cost_items + (proposal_id, request_id, item_description, amount, item_order, created_at, updated_at) + VALUES (:proposalId, :requestId, :description, :amount, :order, NOW(), NOW()) + ON CONFLICT DO NOTHING; + `, { + replacements: { + proposalId, + requestId, + description: item.description, + amount: item.amount, + order: i + } + }); + } + } + } + + console.log('✅ Migrated existing cost breakups to new table'); + } + } catch (error: any) { + console.warn('⚠️ Could not migrate existing cost breakups:', error.message); + // Don't fail the migration if migration of existing data fails + } +} + +export async function down(queryInterface: QueryInterface): Promise { + // Drop indexes first + try { + await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_proposal_order'); + } catch (e) { + // Index might not exist + } + + try { + await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_request_id'); + } catch (e) { + // Index might not exist + } + + try { + await queryInterface.removeIndex('dealer_proposal_cost_items', 'idx_proposal_cost_items_proposal_id'); + } catch (e) { + // Index might not exist + } + + // Drop table + await queryInterface.dropTable('dealer_proposal_cost_items'); + console.log('✅ Dropped dealer_proposal_cost_items table'); +} + diff --git a/src/migrations/20251210-enhance-workflow-templates.ts b/src/migrations/20251210-enhance-workflow-templates.ts new file mode 100644 index 0000000..a4eb06d --- /dev/null +++ b/src/migrations/20251210-enhance-workflow-templates.ts @@ -0,0 +1,174 @@ +import { QueryInterface, DataTypes } from 'sequelize'; + +export async function up(queryInterface: QueryInterface): Promise { + // Check if workflow_templates table exists, if not create it + const [tables] = await queryInterface.sequelize.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'workflow_templates'; + `); + + if (tables.length === 0) { + // Create workflow_templates table if it doesn't exist + await queryInterface.createTable('workflow_templates', { + template_id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4 + }, + template_name: { + type: DataTypes.STRING(200), + allowNull: false + }, + template_code: { + type: DataTypes.STRING(50), + allowNull: true, + unique: true + }, + template_description: { + type: DataTypes.TEXT, + allowNull: true + }, + template_category: { + type: DataTypes.STRING(100), + allowNull: true + }, + workflow_type: { + type: DataTypes.STRING(50), + allowNull: true + }, + approval_levels_config: { + type: DataTypes.JSONB, + allowNull: true + }, + default_tat_hours: { + type: DataTypes.DECIMAL(10, 2), + allowNull: true, + defaultValue: 24 + }, + form_steps_config: { + type: DataTypes.JSONB, + allowNull: true + }, + user_field_mappings: { + type: DataTypes.JSONB, + allowNull: true + }, + dynamic_approver_config: { + type: DataTypes.JSONB, + allowNull: true + }, + is_active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + }, + is_system_template: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + usage_count: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + created_by: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'user_id' + } + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + } + }); + + // Create indexes + await queryInterface.addIndex('workflow_templates', ['template_code'], { + name: 'idx_workflow_templates_template_code', + unique: true + }); + await queryInterface.addIndex('workflow_templates', ['workflow_type'], { + name: 'idx_workflow_templates_workflow_type' + }); + await queryInterface.addIndex('workflow_templates', ['is_active'], { + name: 'idx_workflow_templates_is_active' + }); + } else { + // Table exists, add new columns if they don't exist + const tableDescription = await queryInterface.describeTable('workflow_templates'); + + if (!tableDescription.form_steps_config) { + await queryInterface.addColumn('workflow_templates', 'form_steps_config', { + type: DataTypes.JSONB, + allowNull: true + }); + } + + if (!tableDescription.user_field_mappings) { + await queryInterface.addColumn('workflow_templates', 'user_field_mappings', { + type: DataTypes.JSONB, + allowNull: true + }); + } + + if (!tableDescription.dynamic_approver_config) { + await queryInterface.addColumn('workflow_templates', 'dynamic_approver_config', { + type: DataTypes.JSONB, + allowNull: true + }); + } + + if (!tableDescription.workflow_type) { + await queryInterface.addColumn('workflow_templates', 'workflow_type', { + type: DataTypes.STRING(50), + allowNull: true + }); + } + + if (!tableDescription.is_system_template) { + await queryInterface.addColumn('workflow_templates', 'is_system_template', { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }); + } + } +} + +export async function down(queryInterface: QueryInterface): Promise { + // Remove columns if they exist + const tableDescription = await queryInterface.describeTable('workflow_templates'); + + if (tableDescription.dynamic_approver_config) { + await queryInterface.removeColumn('workflow_templates', 'dynamic_approver_config'); + } + + if (tableDescription.user_field_mappings) { + await queryInterface.removeColumn('workflow_templates', 'user_field_mappings'); + } + + if (tableDescription.form_steps_config) { + await queryInterface.removeColumn('workflow_templates', 'form_steps_config'); + } + + if (tableDescription.workflow_type) { + await queryInterface.removeColumn('workflow_templates', 'workflow_type'); + } + + if (tableDescription.is_system_template) { + await queryInterface.removeColumn('workflow_templates', 'is_system_template'); + } +} + diff --git a/src/models/DealerClaimDetails.ts b/src/models/DealerClaimDetails.ts new file mode 100644 index 0000000..ff6464a --- /dev/null +++ b/src/models/DealerClaimDetails.ts @@ -0,0 +1,258 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface DealerClaimDetailsAttributes { + claimId: string; + requestId: string; + activityName: string; + activityType: string; + dealerCode: string; + dealerName: string; + dealerEmail?: string; + dealerPhone?: string; + dealerAddress?: string; + activityDate?: Date; + location?: string; + periodStartDate?: Date; + periodEndDate?: Date; + estimatedBudget?: number; + closedExpenses?: number; + ioNumber?: string; + ioAvailableBalance?: number; + ioBlockedAmount?: number; + ioRemainingBalance?: number; + sapDocumentNumber?: string; + dmsNumber?: string; + eInvoiceNumber?: string; + eInvoiceDate?: Date; + creditNoteNumber?: string; + creditNoteDate?: Date; + creditNoteAmount?: number; + createdAt: Date; + updatedAt: Date; +} + +interface DealerClaimDetailsCreationAttributes extends Optional {} + +class DealerClaimDetails extends Model implements DealerClaimDetailsAttributes { + public claimId!: string; + public requestId!: string; + public activityName!: string; + public activityType!: string; + public dealerCode!: string; + public dealerName!: string; + public dealerEmail?: string; + public dealerPhone?: string; + public dealerAddress?: string; + public activityDate?: Date; + public location?: string; + public periodStartDate?: Date; + public periodEndDate?: Date; + public estimatedBudget?: number; + public closedExpenses?: number; + public ioNumber?: string; + public ioAvailableBalance?: number; + public ioBlockedAmount?: number; + public ioRemainingBalance?: number; + public sapDocumentNumber?: string; + public dmsNumber?: string; + public eInvoiceNumber?: string; + public eInvoiceDate?: Date; + public creditNoteNumber?: string; + public creditNoteDate?: Date; + public creditNoteAmount?: number; + public createdAt!: Date; + public updatedAt!: Date; + + // Associations + public workflowRequest?: WorkflowRequest; +} + +DealerClaimDetails.init( + { + claimId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'claim_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + activityName: { + type: DataTypes.STRING(500), + allowNull: false, + field: 'activity_name' + }, + activityType: { + type: DataTypes.STRING(100), + allowNull: false, + field: 'activity_type' + }, + dealerCode: { + type: DataTypes.STRING(50), + allowNull: false, + field: 'dealer_code' + }, + dealerName: { + type: DataTypes.STRING(200), + allowNull: false, + field: 'dealer_name' + }, + dealerEmail: { + type: DataTypes.STRING(255), + allowNull: true, + field: 'dealer_email' + }, + dealerPhone: { + type: DataTypes.STRING(20), + allowNull: true, + field: 'dealer_phone' + }, + dealerAddress: { + type: DataTypes.TEXT, + allowNull: true, + field: 'dealer_address' + }, + activityDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'activity_date' + }, + location: { + type: DataTypes.STRING(255), + allowNull: true + }, + periodStartDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'period_start_date' + }, + periodEndDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'period_end_date' + }, + estimatedBudget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'estimated_budget' + }, + closedExpenses: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'closed_expenses' + }, + ioNumber: { + type: DataTypes.STRING(50), + allowNull: true, + field: 'io_number' + }, + ioAvailableBalance: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'io_available_balance' + }, + ioBlockedAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'io_blocked_amount' + }, + ioRemainingBalance: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'io_remaining_balance' + }, + sapDocumentNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'sap_document_number' + }, + dmsNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'dms_number' + }, + eInvoiceNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'e_invoice_number' + }, + eInvoiceDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'e_invoice_date' + }, + creditNoteNumber: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'credit_note_number' + }, + creditNoteDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'credit_note_date' + }, + creditNoteAmount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'credit_note_amount' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }, + { + sequelize, + modelName: 'DealerClaimDetails', + tableName: 'dealer_claim_details', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['request_id'] + }, + { + fields: ['dealer_code'] + }, + { + fields: ['activity_type'] + } + ] + } +); + +// Associations +DealerClaimDetails.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +WorkflowRequest.hasOne(DealerClaimDetails, { + as: 'claimDetails', + foreignKey: 'requestId', + sourceKey: 'requestId' +}); + +export { DealerClaimDetails }; + diff --git a/src/models/DealerCompletionDetails.ts b/src/models/DealerCompletionDetails.ts new file mode 100644 index 0000000..07dc6fe --- /dev/null +++ b/src/models/DealerCompletionDetails.ts @@ -0,0 +1,132 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface DealerCompletionDetailsAttributes { + completionId: string; + requestId: string; + activityCompletionDate: Date; + numberOfParticipants?: number; + closedExpenses?: any; // JSONB array of {description, amount} + totalClosedExpenses?: number; + completionDocuments?: any; // JSONB array of document references + activityPhotos?: any; // JSONB array of photo references + submittedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +interface DealerCompletionDetailsCreationAttributes extends Optional {} + +class DealerCompletionDetails extends Model implements DealerCompletionDetailsAttributes { + public completionId!: string; + public requestId!: string; + public activityCompletionDate!: Date; + public numberOfParticipants?: number; + public closedExpenses?: any; + public totalClosedExpenses?: number; + public completionDocuments?: any; + public activityPhotos?: any; + public submittedAt?: Date; + public createdAt!: Date; + public updatedAt!: Date; + + public workflowRequest?: WorkflowRequest; +} + +DealerCompletionDetails.init( + { + completionId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'completion_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + activityCompletionDate: { + type: DataTypes.DATEONLY, + allowNull: false, + field: 'activity_completion_date' + }, + numberOfParticipants: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'number_of_participants' + }, + closedExpenses: { + type: DataTypes.JSONB, + allowNull: true, + field: 'closed_expenses' + }, + totalClosedExpenses: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'total_closed_expenses' + }, + completionDocuments: { + type: DataTypes.JSONB, + allowNull: true, + field: 'completion_documents' + }, + activityPhotos: { + type: DataTypes.JSONB, + allowNull: true, + field: 'activity_photos' + }, + submittedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'submitted_at' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }, + { + sequelize, + modelName: 'DealerCompletionDetails', + tableName: 'dealer_completion_details', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['request_id'] + } + ] + } +); + +DealerCompletionDetails.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +WorkflowRequest.hasOne(DealerCompletionDetails, { + as: 'completionDetails', + foreignKey: 'requestId', + sourceKey: 'requestId' +}); + +export { DealerCompletionDetails }; + diff --git a/src/models/DealerProposalCostItem.ts b/src/models/DealerProposalCostItem.ts new file mode 100644 index 0000000..b0eb9d3 --- /dev/null +++ b/src/models/DealerProposalCostItem.ts @@ -0,0 +1,123 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { DealerProposalDetails } from './DealerProposalDetails'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface DealerProposalCostItemAttributes { + costItemId: string; + proposalId: string; + requestId: string; + itemDescription: string; + amount: number; + itemOrder: number; + createdAt: Date; + updatedAt: Date; +} + +interface DealerProposalCostItemCreationAttributes extends Optional {} + +class DealerProposalCostItem extends Model implements DealerProposalCostItemAttributes { + public costItemId!: string; + public proposalId!: string; + public requestId!: string; + public itemDescription!: string; + public amount!: number; + public itemOrder!: number; + public createdAt!: Date; + public updatedAt!: Date; + + // Associations + public proposal?: DealerProposalDetails; + public workflowRequest?: WorkflowRequest; +} + +DealerProposalCostItem.init( + { + costItemId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'cost_item_id' + }, + proposalId: { + type: DataTypes.UUID, + allowNull: false, + field: 'proposal_id', + references: { + model: 'dealer_proposal_details', + key: 'proposal_id' + } + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + itemDescription: { + type: DataTypes.STRING(500), + allowNull: false, + field: 'item_description' + }, + amount: { + type: DataTypes.DECIMAL(15, 2), + allowNull: false + }, + itemOrder: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'item_order' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }, + { + sequelize, + modelName: 'DealerProposalCostItem', + tableName: 'dealer_proposal_cost_items', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { fields: ['proposal_id'], name: 'idx_proposal_cost_items_proposal_id' }, + { fields: ['request_id'], name: 'idx_proposal_cost_items_request_id' }, + { fields: ['proposal_id', 'item_order'], name: 'idx_proposal_cost_items_proposal_order' } + ] + } +); + +// Associations +DealerProposalCostItem.belongsTo(DealerProposalDetails, { + as: 'proposal', + foreignKey: 'proposalId', + targetKey: 'proposalId' +}); + +DealerProposalCostItem.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +DealerProposalDetails.hasMany(DealerProposalCostItem, { + as: 'costItems', + foreignKey: 'proposalId', + sourceKey: 'proposalId' +}); + +export { DealerProposalCostItem }; + diff --git a/src/models/DealerProposalDetails.ts b/src/models/DealerProposalDetails.ts new file mode 100644 index 0000000..a6e64d5 --- /dev/null +++ b/src/models/DealerProposalDetails.ts @@ -0,0 +1,146 @@ +import { DataTypes, Model, Optional } from 'sequelize'; +import { sequelize } from '@config/database'; +import { WorkflowRequest } from './WorkflowRequest'; + +interface DealerProposalDetailsAttributes { + proposalId: string; + requestId: string; + proposalDocumentPath?: string; + proposalDocumentUrl?: string; + costBreakup?: any; // JSONB array of {description, amount} + totalEstimatedBudget?: number; + timelineMode?: 'date' | 'days'; + expectedCompletionDate?: Date; + expectedCompletionDays?: number; + dealerComments?: string; + submittedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +interface DealerProposalDetailsCreationAttributes extends Optional {} + +class DealerProposalDetails extends Model implements DealerProposalDetailsAttributes { + public proposalId!: string; + public requestId!: string; + public proposalDocumentPath?: string; + public proposalDocumentUrl?: string; + public costBreakup?: any; + public totalEstimatedBudget?: number; + public timelineMode?: 'date' | 'days'; + public expectedCompletionDate?: Date; + public expectedCompletionDays?: number; + public dealerComments?: string; + public submittedAt?: Date; + public createdAt!: Date; + public updatedAt!: Date; + + public workflowRequest?: WorkflowRequest; +} + +DealerProposalDetails.init( + { + proposalId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'proposal_id' + }, + requestId: { + type: DataTypes.UUID, + allowNull: false, + unique: true, + field: 'request_id', + references: { + model: 'workflow_requests', + key: 'request_id' + } + }, + proposalDocumentPath: { + type: DataTypes.STRING(500), + allowNull: true, + field: 'proposal_document_path' + }, + proposalDocumentUrl: { + type: DataTypes.STRING(500), + allowNull: true, + field: 'proposal_document_url' + }, + costBreakup: { + type: DataTypes.JSONB, + allowNull: true, + field: 'cost_breakup' + }, + totalEstimatedBudget: { + type: DataTypes.DECIMAL(15, 2), + allowNull: true, + field: 'total_estimated_budget' + }, + timelineMode: { + type: DataTypes.STRING(10), + allowNull: true, + field: 'timeline_mode' + }, + expectedCompletionDate: { + type: DataTypes.DATEONLY, + allowNull: true, + field: 'expected_completion_date' + }, + expectedCompletionDays: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'expected_completion_days' + }, + dealerComments: { + type: DataTypes.TEXT, + allowNull: true, + field: 'dealer_comments' + }, + submittedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'submitted_at' + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }, + { + sequelize, + modelName: 'DealerProposalDetails', + tableName: 'dealer_proposal_details', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['request_id'] + } + ] + } +); + +DealerProposalDetails.belongsTo(WorkflowRequest, { + as: 'workflowRequest', + foreignKey: 'requestId', + targetKey: 'requestId' +}); + +WorkflowRequest.hasOne(DealerProposalDetails, { + as: 'proposalDetails', + foreignKey: 'requestId', + sourceKey: 'requestId' +}); + +export { DealerProposalDetails }; + diff --git a/src/models/WorkflowRequest.ts b/src/models/WorkflowRequest.ts index dc48078..c2f94e9 100644 --- a/src/models/WorkflowRequest.ts +++ b/src/models/WorkflowRequest.ts @@ -8,6 +8,8 @@ interface WorkflowRequestAttributes { requestNumber: string; initiatorId: string; templateType: 'CUSTOM' | 'TEMPLATE'; + workflowType?: string; // 'NON_TEMPLATIZED' | 'CLAIM_MANAGEMENT' | etc. + templateId?: string; // Reference to workflow_templates if using admin template title: string; description: string; priority: Priority; @@ -38,6 +40,8 @@ class WorkflowRequest extends Model {} + +class WorkflowTemplate extends Model implements WorkflowTemplateAttributes { + public templateId!: string; + public templateName!: string; + public templateCode?: string; + public templateDescription?: string; + public templateCategory?: string; + public workflowType?: string; + public approvalLevelsConfig?: any; + public defaultTatHours?: number; + public formStepsConfig?: any; + public userFieldMappings?: any; + public dynamicApproverConfig?: any; + public isActive!: boolean; + public isSystemTemplate!: boolean; + public usageCount!: number; + public createdBy?: string; + public createdAt!: Date; + public updatedAt!: Date; + + // Associations + public creator?: User; +} + +WorkflowTemplate.init( + { + templateId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + field: 'template_id' + }, + templateName: { + type: DataTypes.STRING(200), + allowNull: false, + field: 'template_name' + }, + templateCode: { + type: DataTypes.STRING(50), + allowNull: true, + unique: true, + field: 'template_code' + }, + templateDescription: { + type: DataTypes.TEXT, + allowNull: true, + field: 'template_description' + }, + templateCategory: { + type: DataTypes.STRING(100), + allowNull: true, + field: 'template_category' + }, + workflowType: { + type: DataTypes.STRING(50), + allowNull: true, + field: 'workflow_type' + }, + approvalLevelsConfig: { + type: DataTypes.JSONB, + allowNull: true, + field: 'approval_levels_config' + }, + defaultTatHours: { + type: DataTypes.DECIMAL(10, 2), + allowNull: true, + defaultValue: 24, + field: 'default_tat_hours' + }, + formStepsConfig: { + type: DataTypes.JSONB, + allowNull: true, + field: 'form_steps_config' + }, + userFieldMappings: { + type: DataTypes.JSONB, + allowNull: true, + field: 'user_field_mappings' + }, + dynamicApproverConfig: { + type: DataTypes.JSONB, + allowNull: true, + field: 'dynamic_approver_config' + }, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + field: 'is_active' + }, + isSystemTemplate: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'is_system_template' + }, + usageCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + field: 'usage_count' + }, + createdBy: { + type: DataTypes.UUID, + allowNull: true, + field: 'created_by', + references: { + model: 'users', + key: 'user_id' + } + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'created_at' + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'updated_at' + } + }, + { + sequelize, + modelName: 'WorkflowTemplate', + tableName: 'workflow_templates', + timestamps: true, + createdAt: 'created_at', + updatedAt: 'updated_at', + indexes: [ + { + unique: true, + fields: ['template_code'] + }, + { + fields: ['workflow_type'] + }, + { + fields: ['is_active'] + } + ] + } +); + +// Associations +WorkflowTemplate.belongsTo(User, { + as: 'creator', + foreignKey: 'createdBy', + targetKey: 'userId' +}); + +export { WorkflowTemplate }; + diff --git a/src/models/index.ts b/src/models/index.ts index 9e70840..98bf946 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -16,6 +16,11 @@ import { Notification } from './Notification'; import ConclusionRemark from './ConclusionRemark'; import RequestSummary from './RequestSummary'; import SharedSummary from './SharedSummary'; +import { DealerClaimDetails } from './DealerClaimDetails'; +import { DealerProposalDetails } from './DealerProposalDetails'; +import { DealerCompletionDetails } from './DealerCompletionDetails'; +import { DealerProposalCostItem } from './DealerProposalCostItem'; +import { WorkflowTemplate } from './WorkflowTemplate'; // Define associations const defineAssociations = () => { @@ -138,7 +143,12 @@ export { Notification, ConclusionRemark, RequestSummary, - SharedSummary + SharedSummary, + DealerClaimDetails, + DealerProposalDetails, + DealerCompletionDetails, + DealerProposalCostItem, + WorkflowTemplate }; // Export default sequelize instance diff --git a/src/routes/dealer.routes.ts b/src/routes/dealer.routes.ts new file mode 100644 index 0000000..580c350 --- /dev/null +++ b/src/routes/dealer.routes.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { DealerController } from '../controllers/dealer.controller'; +import { authenticateToken } from '../middlewares/auth.middleware'; +import { asyncHandler } from '../middlewares/errorHandler.middleware'; + +const router = Router(); +const dealerController = new DealerController(); + +/** + * @route GET /api/v1/dealers + * @desc Get all dealers + * @access Private + */ +router.get('/', authenticateToken, asyncHandler(dealerController.getAllDealers.bind(dealerController))); + +/** + * @route GET /api/v1/dealers/search + * @desc Search dealers by name, code, or email + * @access Private + */ +router.get('/search', authenticateToken, asyncHandler(dealerController.searchDealers.bind(dealerController))); + +/** + * @route GET /api/v1/dealers/code/:dealerCode + * @desc Get dealer by code + * @access Private + */ +router.get('/code/:dealerCode', authenticateToken, asyncHandler(dealerController.getDealerByCode.bind(dealerController))); + +/** + * @route GET /api/v1/dealers/email/:email + * @desc Get dealer by email + * @access Private + */ +router.get('/email/:email', authenticateToken, asyncHandler(dealerController.getDealerByEmail.bind(dealerController))); + +export default router; + diff --git a/src/routes/dealerClaim.routes.ts b/src/routes/dealerClaim.routes.ts new file mode 100644 index 0000000..e9a82f5 --- /dev/null +++ b/src/routes/dealerClaim.routes.ts @@ -0,0 +1,78 @@ +import { Router } from 'express'; +import { DealerClaimController } from '../controllers/dealerClaim.controller'; +import { authenticateToken } from '../middlewares/auth.middleware'; +import { asyncHandler } from '../middlewares/errorHandler.middleware'; +import multer from 'multer'; +import path from 'path'; + +const router = Router(); +const dealerClaimController = new DealerClaimController(); + +// Configure multer for file uploads (memory storage for direct GCS upload) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB + }, + fileFilter: (req, file, cb) => { + const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.jpg', '.jpeg', '.png', '.zip']; + const ext = path.extname(file.originalname).toLowerCase(); + if (allowedExtensions.includes(ext)) { + cb(null, true); + } else { + cb(new Error(`File type ${ext} not allowed. Allowed types: ${allowedExtensions.join(', ')}`)); + } + }, +}); + +/** + * @route POST /api/v1/dealer-claims + * @desc Create a new dealer claim request + * @access Private + */ +router.post('/', authenticateToken, asyncHandler(dealerClaimController.createClaimRequest.bind(dealerClaimController))); + +/** + * @route GET /api/v1/dealer-claims/:requestId + * @desc Get claim details + * @access Private + */ +router.get('/:requestId', authenticateToken, asyncHandler(dealerClaimController.getClaimDetails.bind(dealerClaimController))); + +/** + * @route POST /api/v1/dealer-claims/:requestId/proposal + * @desc Submit dealer proposal (Step 1) + * @access Private + */ +router.post('/:requestId/proposal', authenticateToken, upload.single('proposalDocument'), asyncHandler(dealerClaimController.submitProposal.bind(dealerClaimController))); + +/** + * @route POST /api/v1/dealer-claims/:requestId/completion + * @desc Submit completion documents (Step 5) + * @access Private + */ +router.post('/:requestId/completion', authenticateToken, asyncHandler(dealerClaimController.submitCompletion.bind(dealerClaimController))); + +/** + * @route PUT /api/v1/dealer-claims/:requestId/io + * @desc Update IO details (Step 3) + * @access Private + */ +router.put('/:requestId/io', authenticateToken, asyncHandler(dealerClaimController.updateIODetails.bind(dealerClaimController))); + +/** + * @route PUT /api/v1/dealer-claims/:requestId/e-invoice + * @desc Update e-invoice details (Step 7) + * @access Private + */ +router.put('/:requestId/e-invoice', authenticateToken, asyncHandler(dealerClaimController.updateEInvoice.bind(dealerClaimController))); + +/** + * @route PUT /api/v1/dealer-claims/:requestId/credit-note + * @desc Update credit note details (Step 8) + * @access Private + */ +router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClaimController.updateCreditNote.bind(dealerClaimController))); + +export default router; + diff --git a/src/routes/index.ts b/src/routes/index.ts index aeca3e9..3bb0071 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -13,6 +13,9 @@ import dashboardRoutes from './dashboard.routes'; import notificationRoutes from './notification.routes'; import conclusionRoutes from './conclusion.routes'; import aiRoutes from './ai.routes'; +import dealerClaimRoutes from './dealerClaim.routes'; +import templateRoutes from './template.routes'; +import dealerRoutes from './dealer.routes'; const router = Router(); @@ -40,6 +43,9 @@ router.use('/notifications', notificationRoutes); router.use('/conclusions', conclusionRoutes); router.use('/ai', aiRoutes); router.use('/summaries', summaryRoutes); +router.use('/dealer-claims', dealerClaimRoutes); +router.use('/templates', templateRoutes); +router.use('/dealers', dealerRoutes); // TODO: Add other route modules as they are implemented // router.use('/approvals', approvalRoutes); diff --git a/src/routes/template.routes.ts b/src/routes/template.routes.ts new file mode 100644 index 0000000..c846759 --- /dev/null +++ b/src/routes/template.routes.ts @@ -0,0 +1,53 @@ +import { Router } from 'express'; +import { TemplateController } from '../controllers/template.controller'; +import { authenticateToken } from '../middlewares/auth.middleware'; +import { asyncHandler } from '../middlewares/errorHandler.middleware'; +import { requireAdmin } from '../middlewares/auth.middleware'; + +const router = Router(); +const templateController = new TemplateController(); + +/** + * @route GET /api/v1/templates + * @desc List all templates (with optional filters) + * @access Private + */ +router.get('/', authenticateToken, asyncHandler(templateController.listTemplates.bind(templateController))); + +/** + * @route GET /api/v1/templates/active + * @desc Get active templates for workflow creation + * @access Private + */ +router.get('/active', authenticateToken, asyncHandler(templateController.getActiveTemplates.bind(templateController))); + +/** + * @route GET /api/v1/templates/:templateId + * @desc Get template by ID + * @access Private + */ +router.get('/:templateId', authenticateToken, asyncHandler(templateController.getTemplate.bind(templateController))); + +/** + * @route POST /api/v1/templates + * @desc Create a new template + * @access Private (Admin only) + */ +router.post('/', authenticateToken, requireAdmin, asyncHandler(templateController.createTemplate.bind(templateController))); + +/** + * @route PUT /api/v1/templates/:templateId + * @desc Update template + * @access Private (Admin only) + */ +router.put('/:templateId', authenticateToken, requireAdmin, asyncHandler(templateController.updateTemplate.bind(templateController))); + +/** + * @route DELETE /api/v1/templates/:templateId + * @desc Delete template (soft delete) + * @access Private (Admin only) + */ +router.delete('/:templateId', authenticateToken, requireAdmin, asyncHandler(templateController.deleteTemplate.bind(templateController))); + +export default router; + diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts index d6141ac..07634ec 100644 --- a/src/scripts/migrate.ts +++ b/src/scripts/migrate.ts @@ -22,6 +22,11 @@ import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-level import * as m19 from '../migrations/20251121-add-ai-model-configs'; import * as m20 from '../migrations/20250122-create-request-summaries'; import * as m21 from '../migrations/20250122-create-shared-summaries'; +import * as m22 from '../migrations/20251210-add-workflow-type-support'; +import * as m23 from '../migrations/20251210-enhance-workflow-templates'; +import * as m24 from '../migrations/20251210-add-template-id-foreign-key'; +import * as m25 from '../migrations/20251210-create-dealer-claim-tables'; +import * as m26 from '../migrations/20251210-create-proposal-cost-items-table'; interface Migration { name: string; @@ -58,6 +63,11 @@ const migrations: Migration[] = [ { name: '20251121-add-ai-model-configs', module: m19 }, { name: '20250122-create-request-summaries', module: m20 }, { name: '20250122-create-shared-summaries', module: m21 }, + { name: '20251210-add-workflow-type-support', module: m22 }, + { name: '20251210-enhance-workflow-templates', module: m23 }, + { name: '20251210-add-template-id-foreign-key', module: m24 }, + { name: '20251210-create-dealer-claim-tables', module: m25 }, + { name: '20251210-create-proposal-cost-items-table', module: m26 }, ]; /** diff --git a/src/scripts/seed-dealers.ts b/src/scripts/seed-dealers.ts new file mode 100644 index 0000000..3e39bcb --- /dev/null +++ b/src/scripts/seed-dealers.ts @@ -0,0 +1,182 @@ +/** + * Seed Dealer Users + * Creates dealer users for claim management workflow + * These users will act as action takers in the workflow + */ + +import { sequelize } from '../config/database'; +import { User } from '../models/User'; +import logger from '../utils/logger'; + +interface DealerData { + email: string; + dealerCode: string; + dealerName: string; + displayName: string; + department?: string; + designation?: string; + phone?: string; + role?: 'USER' | 'MANAGEMENT' | 'ADMIN'; +} + +const dealers: DealerData[] = [ + { + email: 'test.2@royalenfield.com', + dealerCode: 'RE-MH-001', + dealerName: 'Royal Motors Mumbai', + displayName: 'Royal Motors Mumbai', + department: 'Dealer Operations', + designation: 'Dealer', + phone: '+91-9876543210', + role: 'USER', + }, + { + email: 'test.4@royalenfield.com', + dealerCode: 'RE-DL-002', + dealerName: 'Delhi enfield center', + displayName: 'Delhi Enfield Center', + department: 'Dealer Operations', + designation: 'Dealer', + phone: '+91-9876543211', + role: 'USER', + }, +]; + +async function seedDealers(): Promise { + try { + logger.info('[Seed Dealers] Starting dealer user seeding...'); + + for (const dealer of dealers) { + // Check if user already exists + const existingUser = await User.findOne({ + where: { email: dealer.email }, + }); + + if (existingUser) { + // User already exists (likely from Okta SSO login) + const isOktaUser = existingUser.oktaSub && !existingUser.oktaSub.startsWith('dealer-'); + + if (isOktaUser) { + logger.info(`[Seed Dealers] User ${dealer.email} already exists as Okta user (oktaSub: ${existingUser.oktaSub}), updating dealer-specific fields only...`); + } else { + logger.info(`[Seed Dealers] User ${dealer.email} already exists, updating dealer information...`); + } + + // Update existing user with dealer information + // IMPORTANT: Preserve Okta data (oktaSub, role from Okta, etc.) and only update dealer-specific fields + const nameParts = dealer.dealerName.split(' '); + const firstName = nameParts[0] || dealer.dealerName; + const lastName = nameParts.slice(1).join(' ') || ''; + + // Build update object - only update fields that don't conflict with Okta data + const updateData: any = { + // Always update dealer code in employeeId (this is dealer-specific, safe to update) + employeeId: dealer.dealerCode, + }; + + // Only update displayName if it's different or if current one is empty + if (!existingUser.displayName || existingUser.displayName !== dealer.displayName) { + updateData.displayName = dealer.displayName; + } + + // Only update designation if current one doesn't indicate dealer role + if (!existingUser.designation || !existingUser.designation.toLowerCase().includes('dealer')) { + updateData.designation = dealer.designation || existingUser.designation; + } + + // Only update department if it's not set or if we want to ensure "Dealer Operations" + if (!existingUser.department || existingUser.department !== 'Dealer Operations') { + updateData.department = dealer.department || existingUser.department; + } + + // Update phone if not set + if (!existingUser.phone && dealer.phone) { + updateData.phone = dealer.phone; + } + + // Update name parts if not set + if (!existingUser.firstName && firstName) { + updateData.firstName = firstName; + } + if (!existingUser.lastName && lastName) { + updateData.lastName = lastName; + } + + await existingUser.update(updateData); + + if (isOktaUser) { + logger.info(`[Seed Dealers] ✅ Updated existing Okta user ${dealer.email} with dealer code: ${dealer.dealerCode}`); + logger.info(`[Seed Dealers] Preserved Okta data: oktaSub=${existingUser.oktaSub}, role=${existingUser.role}`); + } else { + logger.info(`[Seed Dealers] ✅ Updated user ${dealer.email} with dealer code: ${dealer.dealerCode}`); + } + } else { + // User doesn't exist - create new dealer user + // NOTE: If dealer is an Okta user, they should login via SSO first to be created automatically + // This creates a placeholder user that will be updated when they login via SSO + logger.warn(`[Seed Dealers] User ${dealer.email} not found in database. Creating placeholder user...`); + logger.warn(`[Seed Dealers] ⚠️ If this user is an Okta user, they should login via SSO first to be created automatically.`); + logger.warn(`[Seed Dealers] ⚠️ The oktaSub will be updated when they login via SSO.`); + + // Generate a UUID for userId + const { v4: uuidv4 } = require('uuid'); + const userId = uuidv4(); + + const nameParts = dealer.dealerName.split(' '); + const firstName = nameParts[0] || dealer.dealerName; + const lastName = nameParts.slice(1).join(' ') || ''; + + await User.create({ + userId, + email: dealer.email.toLowerCase(), + displayName: dealer.displayName, + firstName, + lastName, + department: dealer.department || 'Dealer Operations', + designation: dealer.designation || 'Dealer', + phone: dealer.phone, + role: dealer.role || 'USER', + employeeId: dealer.dealerCode, // Store dealer code in employeeId field + isActive: true, + // Set placeholder oktaSub - will be updated when user logs in via SSO + // Using a recognizable pattern so we know it's a placeholder + oktaSub: `dealer-${dealer.dealerCode}-pending-sso`, + emailNotificationsEnabled: true, + pushNotificationsEnabled: false, + inAppNotificationsEnabled: true, + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + logger.info(`[Seed Dealers] ⚠️ Created placeholder dealer user: ${dealer.email} (${dealer.dealerCode})`); + logger.info(`[Seed Dealers] ⚠️ User should login via SSO to update oktaSub field with real Okta subject ID`); + } + } + + logger.info('[Seed Dealers] ✅ Dealer seeding completed successfully'); + } catch (error) { + logger.error('[Seed Dealers] ❌ Error seeding dealers:', error); + throw error; + } +} + +// Run if called directly +if (require.main === module) { + sequelize + .authenticate() + .then(() => { + logger.info('[Seed Dealers] Database connection established'); + return seedDealers(); + }) + .then(() => { + logger.info('[Seed Dealers] Seeding completed'); + process.exit(0); + }) + .catch((error) => { + logger.error('[Seed Dealers] Seeding failed:', error); + process.exit(1); + }); +} + +export { seedDealers, dealers }; + diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 52e0353..6e3513d 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -7,6 +7,201 @@ import logger, { logAuthEvent } from '../utils/logger'; import axios from 'axios'; export class AuthService { + /** + * Fetch user details from Okta Users API (full profile with manager, employeeID, etc.) + * Falls back to userinfo endpoint if Users API fails or token is not configured + */ + private async fetchUserFromOktaUsersAPI(oktaSub: string, email: string, accessToken: string): Promise { + try { + // Check if API token is configured + if (!ssoConfig.oktaApiToken || ssoConfig.oktaApiToken.trim() === '') { + logger.info('OKTA_API_TOKEN not configured, will use userinfo endpoint as fallback'); + return null; + } + + // Try to fetch from Users API using email first (as shown in curl example) + // If email lookup fails, try with oktaSub (user ID) + let usersApiResponse: any = null; + + // First attempt: Use email (preferred method as shown in curl example) + if (email) { + const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(email)}`; + + logger.info('Fetching user from Okta Users API (using email)', { + endpoint: usersApiEndpoint.replace(email, email.substring(0, 5) + '...'), + hasApiToken: !!ssoConfig.oktaApiToken, + }); + + try { + const response = await axios.get(usersApiEndpoint, { + headers: { + 'Authorization': `SSWS ${ssoConfig.oktaApiToken}`, + 'Accept': 'application/json', + }, + validateStatus: (status) => status < 500, // Don't throw on 4xx errors + }); + + if (response.status === 200 && response.data) { + logger.info('Successfully fetched user from Okta Users API (using email)', { + userId: response.data.id, + hasProfile: !!response.data.profile, + }); + return response.data; + } + } catch (emailError: any) { + logger.warn('Users API lookup with email failed, will try with oktaSub', { + status: emailError.response?.status, + error: emailError.message, + }); + } + } + + // Second attempt: Use oktaSub (user ID) if email lookup failed + if (oktaSub) { + const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(oktaSub)}`; + + logger.info('Fetching user from Okta Users API (using oktaSub)', { + endpoint: usersApiEndpoint.replace(oktaSub, oktaSub.substring(0, 10) + '...'), + hasApiToken: !!ssoConfig.oktaApiToken, + }); + + try { + const response = await axios.get(usersApiEndpoint, { + headers: { + 'Authorization': `SSWS ${ssoConfig.oktaApiToken}`, + 'Accept': 'application/json', + }, + validateStatus: (status) => status < 500, + }); + + if (response.status === 200 && response.data) { + logger.info('Successfully fetched user from Okta Users API (using oktaSub)', { + userId: response.data.id, + hasProfile: !!response.data.profile, + }); + return response.data; + } else { + logger.warn('Okta Users API returned non-200 status (oktaSub lookup)', { + status: response.status, + statusText: response.statusText, + }); + } + } catch (oktaSubError: any) { + logger.warn('Users API lookup with oktaSub also failed', { + status: oktaSubError.response?.status, + error: oktaSubError.message, + }); + } + } + + return null; + } catch (error: any) { + logger.warn('Failed to fetch from Okta Users API, will use userinfo fallback', { + error: error.message, + status: error.response?.status, + }); + return null; + } + } + + /** + * Extract user data from Okta Users API response + */ + private extractUserDataFromUsersAPI(oktaUserResponse: any, oktaSub: string): SSOUserData | null { + try { + const profile = oktaUserResponse.profile || {}; + + const userData: SSOUserData = { + oktaSub: oktaSub || oktaUserResponse.id || '', + email: profile.email || profile.login || '', + employeeId: profile.employeeID || profile.employeeId || profile.employee_id || undefined, + firstName: profile.firstName || undefined, + lastName: profile.lastName || undefined, + displayName: profile.displayName || undefined, + department: profile.department || undefined, + designation: profile.title || profile.designation || undefined, + phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined, + manager: profile.manager || undefined, // Store manager name if available + }; + + // Validate required fields + if (!userData.oktaSub || !userData.email) { + logger.warn('Users API response missing required fields (oktaSub or email)'); + return null; + } + + logger.info('Extracted user data from Okta Users API', { + oktaSub: userData.oktaSub, + email: userData.email, + employeeId: userData.employeeId || 'not provided', + hasManager: !!userData.manager, + hasDepartment: !!userData.department, + hasDesignation: !!userData.designation, + }); + + return userData; + } catch (error) { + logger.error('Error extracting user data from Users API response', error); + return null; + } + } + + /** + * Extract user data from Okta userinfo endpoint (fallback) + */ + private extractUserDataFromUserInfo(oktaUser: any, oktaSub: string): SSOUserData { + // Extract oktaSub (required) + const sub = oktaSub || oktaUser.sub || ''; + if (!sub) { + throw new Error('Okta sub (subject identifier) is required but not found in response'); + } + + // Extract employeeId (optional) + const employeeId = + oktaUser.employeeId || + oktaUser.employee_id || + oktaUser.empId || + oktaUser.employeeNumber || + undefined; + + const userData: SSOUserData = { + oktaSub: sub, + email: oktaUser.email || '', + employeeId: employeeId, + }; + + // Validate: Ensure we're not accidentally using oktaSub as employeeId + if (employeeId === sub) { + logger.warn('Warning: employeeId matches oktaSub - this should not happen unless explicitly set in Okta', { + oktaSub: sub, + employeeId, + }); + userData.employeeId = undefined; + } + + // Only set optional fields if they have values + if (oktaUser.given_name || oktaUser.firstName) { + userData.firstName = oktaUser.given_name || oktaUser.firstName; + } + if (oktaUser.family_name || oktaUser.lastName) { + userData.lastName = oktaUser.family_name || oktaUser.lastName; + } + if (oktaUser.name) { + userData.displayName = oktaUser.name; + } + if (oktaUser.department) { + userData.department = oktaUser.department; + } + if (oktaUser.title || oktaUser.designation) { + userData.designation = oktaUser.title || oktaUser.designation; + } + if (oktaUser.phone_number || oktaUser.phone) { + userData.phone = oktaUser.phone_number || oktaUser.phone; + } + + return userData; + } + /** * Handle SSO callback from frontend * Creates new user or updates existing user based on employeeId @@ -59,6 +254,7 @@ export class AuthService { if (userData.department) userUpdateData.department = userData.department; if (userData.designation) userUpdateData.designation = userData.designation; if (userData.phone) userUpdateData.phone = userData.phone; + if (userData.manager) userUpdateData.manager = userData.manager; // Manager name from Okta // Check if user exists by email (primary identifier) let user = await User.findOne({ @@ -88,6 +284,7 @@ export class AuthService { department: userData.department || null, designation: userData.designation || null, phone: userData.phone || null, + manager: userData.manager || null, // Manager name from Okta isActive: true, role: 'USER', lastLogin: new Date() @@ -306,45 +503,29 @@ export class AuthService { }, }); - const oktaUser = userInfoResponse.data; - - // Step 3: Extract user data from Okta response - const oktaSub = oktaUser.sub || ''; + const oktaUserInfo = userInfoResponse.data; + const oktaSub = oktaUserInfo.sub || ''; + if (!oktaSub) { throw new Error('Okta sub (subject identifier) not found in response'); } - const employeeId = - oktaUser.employeeId || - oktaUser.employee_id || - oktaUser.empId || - oktaUser.employeeNumber || - undefined; + // Step 3: Try Users API first (provides full profile including manager, employeeID, etc.) + let userData: SSOUserData | null = null; + const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token); + + if (usersApiResponse) { + userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub); + } - const userData: SSOUserData = { - oktaSub: oktaSub, - email: oktaUser.email || username, - employeeId: employeeId, - }; - - // Add optional fields - if (oktaUser.given_name || oktaUser.firstName) { - userData.firstName = oktaUser.given_name || oktaUser.firstName; - } - if (oktaUser.family_name || oktaUser.lastName) { - userData.lastName = oktaUser.family_name || oktaUser.lastName; - } - if (oktaUser.name) { - userData.displayName = oktaUser.name; - } - if (oktaUser.department) { - userData.department = oktaUser.department; - } - if (oktaUser.title || oktaUser.designation) { - userData.designation = oktaUser.title || oktaUser.designation; - } - if (oktaUser.phone_number || oktaUser.phone) { - userData.phone = oktaUser.phone_number || oktaUser.phone; + // Fallback to userinfo endpoint if Users API failed or returned null + if (!userData) { + logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)'); + userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub); + // Override email with username if needed + if (!userData.email && username) { + userData.email = username; + } } logger.info('User data extracted from Okta', { @@ -483,7 +664,8 @@ export class AuthService { hasIdToken: !!id_token, }); - // Get user info from Okta using access token + // Step 1: Try to get user info from Okta Users API (full profile with manager, employeeID, etc.) + // First, get oktaSub from userinfo to use as user ID const userInfoEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/userinfo`; const userInfoResponse = await axios.get(userInfoEndpoint, { headers: { @@ -491,98 +673,35 @@ export class AuthService { }, }); - const oktaUser = userInfoResponse.data; - - // Log the full Okta response to see what attributes are available - logger.info('Okta userinfo response received', { - availableKeys: Object.keys(oktaUser || {}), - sub: oktaUser.sub, - email: oktaUser.email, - // Log specific fields that might be employeeId - employeeId: oktaUser.employeeId || oktaUser.employee_id || oktaUser.empId || 'NOT_FOUND', - // Log other common custom attributes - customAttributes: Object.keys(oktaUser || {}).filter(key => - key.includes('employee') || key.includes('emp') || key.includes('id') - ), - }); - - // Extract oktaSub (required) - this is the Okta subject identifier - // IMPORTANT: Do NOT use oktaSub for employeeId - they are separate fields - const oktaSub = oktaUser.sub || ''; + const oktaUserInfo = userInfoResponse.data; + const oktaSub = oktaUserInfo.sub || ''; + if (!oktaSub) { throw new Error('Okta sub (subject identifier) is required but not found in response'); } - // Extract employeeId (optional) - ONLY from custom Okta attributes, NOT from sub - // Check multiple possible sources for actual employee ID attribute: - // 1. Custom Okta attribute: employeeId, employee_id, empId, employeeNumber - // 2. Leave undefined if not found - DO NOT use oktaSub/sub as fallback - const employeeId = - oktaUser.employeeId || - oktaUser.employee_id || - oktaUser.empId || - oktaUser.employeeNumber || - undefined; // Explicitly undefined if not found - oktaSub is stored separately - - // Extract user data from Okta response - // Adjust these mappings based on your Okta user profile attributes - // Only include fields that have values, leave others undefined for optional handling - const userData: SSOUserData = { - oktaSub: oktaSub, // Required - Okta subject identifier (stored in okta_sub column) - email: oktaUser.email || '', - employeeId: employeeId, // Optional - Only if provided as custom attribute, NOT oktaSub - }; - - // Validate: Ensure we're not accidentally using oktaSub as employeeId - if (employeeId === oktaSub) { - logger.warn('Warning: employeeId matches oktaSub - this should not happen unless explicitly set in Okta', { - oktaSub, - employeeId, - }); - // Clear employeeId to avoid confusion - user can update it later if needed - userData.employeeId = undefined; + // Try Users API first (provides full profile including manager, employeeID, etc.) + let userData: SSOUserData | null = null; + const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token); + + if (usersApiResponse) { + userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub); } - logger.info('User data extracted from Okta', { - oktaSub: oktaSub, - email: oktaUser.email, - employeeId: employeeId || 'not provided (optional)', - employeeIdSource: oktaUser.employeeId ? 'employeeId attribute' : - oktaUser.employee_id ? 'employee_id attribute' : - oktaUser.empId ? 'empId attribute' : - 'not found', - note: 'Using email as primary identifier, oktaSub for uniqueness', - }); - - // Only set optional fields if they have values - if (oktaUser.given_name || oktaUser.firstName) { - userData.firstName = oktaUser.given_name || oktaUser.firstName; - } - if (oktaUser.family_name || oktaUser.lastName) { - userData.lastName = oktaUser.family_name || oktaUser.lastName; - } - if (oktaUser.name) { - userData.displayName = oktaUser.name; - } - if (oktaUser.department) { - userData.department = oktaUser.department; - } - if (oktaUser.title || oktaUser.designation) { - userData.designation = oktaUser.title || oktaUser.designation; - } - if (oktaUser.phone_number || oktaUser.phone) { - userData.phone = oktaUser.phone_number || oktaUser.phone; + // Fallback to userinfo endpoint if Users API failed or returned null + if (!userData) { + logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)'); + userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub); } - logger.info('Extracted user data from Okta', { - employeeId: userData.employeeId, + logger.info('Final extracted user data', { + oktaSub: userData.oktaSub, email: userData.email, - hasFirstName: !!userData.firstName, - hasLastName: !!userData.lastName, - hasDisplayName: !!userData.displayName, + employeeId: userData.employeeId || 'not provided', + hasManager: !!(userData as any).manager, hasDepartment: !!userData.department, hasDesignation: !!userData.designation, - hasPhone: !!userData.phone, + source: usersApiResponse ? 'Users API' : 'userinfo endpoint', }); // Handle SSO callback to create/update user and generate our tokens diff --git a/src/services/dealer.service.ts b/src/services/dealer.service.ts new file mode 100644 index 0000000..1a45c14 --- /dev/null +++ b/src/services/dealer.service.ts @@ -0,0 +1,166 @@ +/** + * Dealer Service + * Handles dealer-related operations for claim management + */ + +import { User } from '../models/User'; +import { Op } from 'sequelize'; +import logger from '../utils/logger'; + +export interface DealerInfo { + userId: string; + email: string; + dealerCode: string; + dealerName: string; + displayName: string; + phone?: string; + department?: string; + designation?: string; +} + +/** + * Get all dealers (users with dealer designation or employeeId starting with 'RE-') + */ +export async function getAllDealers(): Promise { + try { + const dealers = await User.findAll({ + where: { + [Op.or]: [ + { designation: { [Op.iLike]: '%dealer%' } as any }, + { employeeId: { [Op.like]: 'RE-%' } as any }, + { department: { [Op.iLike]: '%dealer%' } as any }, + ], + isActive: true, + }, + order: [['displayName', 'ASC']], + }); + + return dealers.map((dealer) => ({ + userId: dealer.userId, + email: dealer.email, + dealerCode: dealer.employeeId || '', // Dealer code stored in employeeId + dealerName: dealer.displayName || dealer.email, + displayName: dealer.displayName || dealer.email, + phone: dealer.phone || undefined, + department: dealer.department || undefined, + designation: dealer.designation || undefined, + })); + } catch (error) { + logger.error('[DealerService] Error fetching dealers:', error); + throw error; + } +} + +/** + * Get dealer by code + */ +export async function getDealerByCode(dealerCode: string): Promise { + try { + const dealer = await User.findOne({ + where: { + employeeId: dealerCode, + isActive: true, + }, + }); + + if (!dealer) { + return null; + } + + return { + userId: dealer.userId, + email: dealer.email, + dealerCode: dealer.employeeId || '', + dealerName: dealer.displayName || dealer.email, + displayName: dealer.displayName || dealer.email, + phone: dealer.phone || undefined, + department: dealer.department || undefined, + designation: dealer.designation || undefined, + }; + } catch (error) { + logger.error('[DealerService] Error fetching dealer by code:', error); + throw error; + } +} + +/** + * Get dealer by email + */ +export async function getDealerByEmail(email: string): Promise { + try { + const dealer = await User.findOne({ + where: { + email: email.toLowerCase(), + isActive: true, + [Op.or]: [ + { designation: { [Op.iLike]: '%dealer%' } as any }, + { employeeId: { [Op.like]: 'RE-%' } as any }, + ], + }, + }); + + if (!dealer) { + return null; + } + + return { + userId: dealer.userId, + email: dealer.email, + dealerCode: dealer.employeeId || '', + dealerName: dealer.displayName || dealer.email, + displayName: dealer.displayName || dealer.email, + phone: dealer.phone || undefined, + department: dealer.department || undefined, + designation: dealer.designation || undefined, + }; + } catch (error) { + logger.error('[DealerService] Error fetching dealer by email:', error); + throw error; + } +} + +/** + * Search dealers by name or code + */ +export async function searchDealers(searchTerm: string): Promise { + try { + const dealers = await User.findAll({ + where: { + [Op.and]: [ + { + [Op.or]: [ + { designation: { [Op.iLike]: '%dealer%' } as any }, + { employeeId: { [Op.like]: 'RE-%' } as any }, + { department: { [Op.iLike]: '%dealer%' } as any }, + ], + }, + { + [Op.or]: [ + { displayName: { [Op.iLike]: `%${searchTerm}%` } as any }, + { email: { [Op.iLike]: `%${searchTerm}%` } as any }, + { employeeId: { [Op.iLike]: `%${searchTerm}%` } as any }, + ], + }, + { isActive: true }, + ], + }, + order: [['displayName', 'ASC']], + limit: 50, // Limit results + }); + + return dealers.map((dealer) => ({ + userId: dealer.userId, + email: dealer.email, + dealerCode: dealer.employeeId || '', + dealerName: dealer.displayName || dealer.email, + displayName: dealer.displayName || dealer.email, + phone: dealer.phone || undefined, + department: dealer.department || undefined, + designation: dealer.designation || undefined, + })); + } catch (error) { + logger.error('[DealerService] Error searching dealers:', error); + throw error; + } +} + diff --git a/src/services/dealerClaim.service.ts b/src/services/dealerClaim.service.ts new file mode 100644 index 0000000..9312716 --- /dev/null +++ b/src/services/dealerClaim.service.ts @@ -0,0 +1,964 @@ +import { WorkflowRequest } from '../models/WorkflowRequest'; +import { DealerClaimDetails } from '../models/DealerClaimDetails'; +import { DealerProposalDetails } from '../models/DealerProposalDetails'; +import { DealerCompletionDetails } from '../models/DealerCompletionDetails'; +import { DealerProposalCostItem } from '../models/DealerProposalCostItem'; +import { ApprovalLevel } from '../models/ApprovalLevel'; +import { Participant } from '../models/Participant'; +import { User } from '../models/User'; +import { WorkflowService } from './workflow.service'; +import { ApprovalService } from './approval.service'; +import { generateRequestNumber } from '../utils/helpers'; +import { Priority, WorkflowStatus, ApprovalStatus } from '../types/common.types'; +import { sapIntegrationService } from './sapIntegration.service'; +import { dmsIntegrationService } from './dmsIntegration.service'; +import logger from '../utils/logger'; + +/** + * Dealer Claim Service + * Handles business logic specific to dealer claim management workflow + */ +export class DealerClaimService { + private workflowService = new WorkflowService(); + private approvalService = new ApprovalService(); + + /** + * Create a new dealer claim request + */ + async createClaimRequest( + userId: string, + claimData: { + activityName: string; + activityType: string; + dealerCode: string; + dealerName: string; + dealerEmail?: string; + dealerPhone?: string; + dealerAddress?: string; + activityDate?: Date; + location: string; + requestDescription: string; + periodStartDate?: Date; + periodEndDate?: Date; + estimatedBudget?: number; + } + ): Promise { + try { + // Generate request number + const requestNumber = await generateRequestNumber(); + + // Create workflow request + // For claim management, requests are submitted immediately (not drafts) + // Step 1 will be active for dealer to submit proposal + const workflowRequest = await WorkflowRequest.create({ + initiatorId: userId, + requestNumber, + templateType: 'CUSTOM', + workflowType: 'CLAIM_MANAGEMENT', + title: `${claimData.activityName} - Claim Request`, + description: claimData.requestDescription, + priority: Priority.STANDARD, + status: WorkflowStatus.PENDING, // Submitted, not draft + totalLevels: 8, // Fixed 8-step workflow for claim management + currentLevel: 1, // Step 1: Dealer Proposal Submission + totalTatHours: 0, // Will be calculated from approval levels + isDraft: false, // Not a draft - submitted and ready for workflow + isDeleted: false, + }); + + // Create claim details + await DealerClaimDetails.create({ + requestId: workflowRequest.requestId, + activityName: claimData.activityName, + activityType: claimData.activityType, + dealerCode: claimData.dealerCode, + dealerName: claimData.dealerName, + dealerEmail: claimData.dealerEmail, + dealerPhone: claimData.dealerPhone, + dealerAddress: claimData.dealerAddress, + activityDate: claimData.activityDate, + location: claimData.location, + periodStartDate: claimData.periodStartDate, + periodEndDate: claimData.periodEndDate, + estimatedBudget: claimData.estimatedBudget, + }); + + // Create 8 approval levels for claim management workflow + await this.createClaimApprovalLevels(workflowRequest.requestId, userId, claimData.dealerEmail); + + logger.info(`[DealerClaimService] Created claim request: ${workflowRequest.requestNumber}`); + return workflowRequest; + } catch (error) { + logger.error('[DealerClaimService] Error creating claim request:', error); + throw error; + } + } + + /** + * Create 8-step approval levels for claim management + * Maps approvers based on step requirements: + * - Step 1 & 5: Dealer (external user via email) + * - Step 2 & 6: Initiator (requestor) + * - Step 3: Department Lead (resolved from initiator's department/manager) + * - Step 4 & 7: System (auto-processed) + * - Step 8: Finance Team (resolved from department/role) + */ + private async createClaimApprovalLevels( + requestId: string, + initiatorId: string, + dealerEmail?: string + ): Promise { + const initiator = await User.findByPk(initiatorId); + if (!initiator) { + throw new Error('Initiator not found'); + } + + // Resolve Department Lead for Step 3 + const departmentLead = await this.resolveDepartmentLead(initiator); + + // Resolve Finance Team for Step 8 + const financeApprover = await this.resolveFinanceApprover(); + + // Step 1: Dealer Proposal Submission (72 hours) - Dealer submits proposal + // Step 2: Requestor Evaluation (48 hours) - Initiator evaluates + // Step 3: Department Lead Approval (72 hours) - Department Lead approves and blocks IO + // Step 4: Activity Creation (Auto - 1 hour) - System auto-processes + // Step 5: Dealer Completion Documents (120 hours) - Dealer submits completion docs + // Step 6: Requestor Claim Approval (48 hours) - Initiator approves completion + // Step 7: E-Invoice Generation (Auto - 1 hour) - System generates via DMS + // Step 8: Credit Note Confirmation (48 hours) - Finance confirms credit note (FINAL) + + const steps = [ + { + level: 1, + name: 'Dealer Proposal Submission', + tatHours: 72, + isAuto: false, + approverType: 'dealer' as const, + approverEmail: dealerEmail, + approverId: null, // Dealer may not exist in system, will use email + }, + { + level: 2, + name: 'Requestor Evaluation', + tatHours: 48, + isAuto: false, + approverType: 'initiator' as const, + approverId: initiatorId, + approverEmail: initiator.email, + }, + { + level: 3, + name: 'Department Lead Approval', + tatHours: 72, + isAuto: false, + approverType: 'department_lead' as const, + approverId: departmentLead?.userId || null, + approverEmail: departmentLead?.email || initiator.manager || 'deptlead@royalenfield.com', + }, + { + level: 4, + name: 'Activity Creation', + tatHours: 1, + isAuto: true, + approverType: 'system' as const, + approverId: null, + approverEmail: 'system@royalenfield.com', + }, + { + level: 5, + name: 'Dealer Completion Documents', + tatHours: 120, + isAuto: false, + approverType: 'dealer' as const, + approverEmail: dealerEmail, + approverId: null, + }, + { + level: 6, + name: 'Requestor Claim Approval', + tatHours: 48, + isAuto: false, + approverType: 'initiator' as const, + approverId: initiatorId, + approverEmail: initiator.email, + }, + { + level: 7, + name: 'E-Invoice Generation', + tatHours: 1, + isAuto: true, + approverType: 'system' as const, + approverId: null, + approverEmail: 'system@royalenfield.com', + }, + { + level: 8, + name: 'Credit Note Confirmation', + tatHours: 48, + isAuto: false, + approverType: 'finance' as const, + approverId: financeApprover?.userId || null, + approverEmail: financeApprover?.email || 'finance@royalenfield.com', + }, + ]; + + for (const step of steps) { + // For dealer steps, try to find dealer user, but allow email-only if not found + let approverId = step.approverId; + let approverEmail = step.approverEmail || ''; + let approverName = 'System'; + + if (step.isAuto) { + // For system/auto steps, use initiator's ID (system steps don't need real approver) + approverId = initiatorId; + approverName = 'System Auto-Process'; + approverEmail = approverEmail || 'system@royalenfield.com'; + } else if (step.approverType === 'dealer' && step.approverEmail) { + // Try to find dealer user - must exist in system + const dealerUser = await User.findOne({ where: { email: step.approverEmail } }); + if (dealerUser) { + approverId = dealerUser.userId; + approverName = dealerUser.displayName || dealerUser.email || 'Dealer'; + approverEmail = dealerUser.email; + } else { + // Dealer not found - this should not happen if dealers are seeded + // Use initiator as fallback for now, but log a warning + logger.warn(`[DealerClaimService] Dealer ${step.approverEmail} not found in system, using initiator as fallback`); + approverId = initiatorId; + approverName = `Dealer (${step.approverEmail})`; + approverEmail = step.approverEmail; + } + } else if (step.approverType === 'initiator') { + approverId = initiatorId; + approverName = initiator.displayName || initiator.email || 'Requestor'; + approverEmail = initiator.email; + } else if (step.approverType === 'department_lead') { + if (departmentLead) { + approverId = departmentLead.userId; + approverName = departmentLead.displayName || departmentLead.email || 'Department Lead'; + approverEmail = departmentLead.email; + } else { + // Department lead not found - use initiator as fallback + logger.warn(`[DealerClaimService] Department lead not found for department ${initiator.department}, using initiator as fallback`); + approverId = initiatorId; + approverName = 'Department Lead (Not Found)'; + approverEmail = initiator.email; + } + } else if (step.approverType === 'finance') { + if (financeApprover) { + approverId = financeApprover.userId; + approverName = financeApprover.displayName || financeApprover.email || 'Finance Team'; + approverEmail = financeApprover.email; + } else { + // Finance approver not found - use initiator as fallback + logger.warn(`[DealerClaimService] Finance approver not found, using initiator as fallback`); + approverId = initiatorId; + approverName = 'Finance Team (Not Found)'; + approverEmail = initiator.email; + } + } + + // Ensure we always have a valid approverId (should never be null at this point) + if (!approverId) { + logger.error(`[DealerClaimService] No approverId resolved for step ${step.level}, using initiator as fallback`); + approverId = initiatorId; + approverEmail = approverEmail || initiator.email; + approverName = approverName || 'Unknown Approver'; + } + + await ApprovalLevel.create({ + requestId, + levelNumber: step.level, + levelName: step.name, + approverId: approverId, // Always a valid user ID now + approverEmail, + approverName, + tatHours: step.tatHours, + // tatDays is calculated later when needed: 1 day = 8 working hours + // Formula: Math.ceil(tatHours / 8) + status: step.level === 1 ? ApprovalStatus.PENDING : ApprovalStatus.PENDING, + isFinalApprover: step.level === 8, + elapsedHours: 0, + remainingHours: step.tatHours, + tatPercentageUsed: 0, + }); + } + } + + /** + * Resolve Department Lead based on initiator's department/manager + * If multiple users found with same department, uses the first one + */ + private async resolveDepartmentLead(initiator: User): Promise { + try { + const { Op } = await import('sequelize'); + + logger.info(`[DealerClaimService] Resolving department lead for initiator: ${initiator.email}, department: ${initiator.department}, manager: ${initiator.manager}`); + + // Priority 1: Find user with MANAGEMENT role in same department + if (initiator.department) { + const deptLeads = await User.findAll({ + where: { + department: initiator.department, + role: 'MANAGEMENT' as any, + isActive: true, + }, + order: [['createdAt', 'ASC']], // Get first one if multiple + limit: 1, + }); + if (deptLeads.length > 0) { + logger.info(`[DealerClaimService] Found department lead by MANAGEMENT role: ${deptLeads[0].email} for department: ${initiator.department}`); + return deptLeads[0]; + } else { + logger.debug(`[DealerClaimService] No MANAGEMENT role user found in department: ${initiator.department}`); + } + } else { + logger.debug(`[DealerClaimService] Initiator has no department set`); + } + + // Priority 2: Find users with "Department Lead", "Team Lead", "Team Manager", "Group Manager", "Assistant Manager", "Deputy Manager" in designation, same department + if (initiator.department) { + const leads = await User.findAll({ + where: { + department: initiator.department, + designation: { + [Op.or]: [ + { [Op.iLike]: '%department lead%' }, + { [Op.iLike]: '%departmentlead%' }, + { [Op.iLike]: '%dept lead%' }, + { [Op.iLike]: '%deptlead%' }, + { [Op.iLike]: '%team lead%' }, + { [Op.iLike]: '%team manager%' }, + { [Op.iLike]: '%group manager%' }, + { [Op.iLike]: '%assistant manager%' }, + { [Op.iLike]: '%deputy manager%' }, + { [Op.iLike]: '%lead%' }, + { [Op.iLike]: '%head%' }, + { [Op.iLike]: '%manager%' }, + ], + } as any, + isActive: true, + }, + order: [['createdAt', 'ASC']], // Get first one if multiple + limit: 1, + }); + if (leads.length > 0) { + logger.info(`[DealerClaimService] Found lead by designation: ${leads[0].email} (designation: ${leads[0].designation})`); + return leads[0]; + } + } + + // Priority 3: Use initiator's manager field + if (initiator.manager) { + const manager = await User.findOne({ + where: { + email: initiator.manager, + isActive: true, + }, + }); + if (manager) { + logger.info(`[DealerClaimService] Using initiator's manager as department lead: ${manager.email}`); + return manager; + } + } + + // Priority 4: Find any user in same department (fallback - use first one) + if (initiator.department) { + const anyDeptUser = await User.findOne({ + where: { + department: initiator.department, + isActive: true, + userId: { [Op.ne]: initiator.userId }, // Exclude initiator + }, + order: [['createdAt', 'ASC']], + }); + if (anyDeptUser) { + logger.warn(`[DealerClaimService] Using first available user in department as fallback: ${anyDeptUser.email} (designation: ${anyDeptUser.designation}, role: ${anyDeptUser.role})`); + return anyDeptUser; + } else { + logger.debug(`[DealerClaimService] No other users found in department: ${initiator.department}`); + } + } + + // Priority 5: Search across all departments for users with "Department Lead" designation + logger.debug(`[DealerClaimService] Trying to find any user with "Department Lead" designation...`); + const anyDeptLead = await User.findOne({ + where: { + designation: { + [Op.iLike]: '%department lead%', + } as any, + isActive: true, + userId: { [Op.ne]: initiator.userId }, // Exclude initiator + }, + order: [['createdAt', 'ASC']], + }); + if (anyDeptLead) { + logger.warn(`[DealerClaimService] Found user with "Department Lead" designation across all departments: ${anyDeptLead.email} (department: ${anyDeptLead.department})`); + return anyDeptLead; + } + + logger.warn(`[DealerClaimService] Could not resolve department lead for initiator: ${initiator.email} (department: ${initiator.department}, manager: ${initiator.manager})`); + return null; + } catch (error) { + logger.error('[DealerClaimService] Error resolving department lead:', error); + return null; + } + } + + /** + * Resolve Finance Team approver for Step 8 + */ + private async resolveFinanceApprover(): Promise { + try { + const { Op } = await import('sequelize'); + + // Priority 1: Find user with department containing "Finance" and MANAGEMENT role + const financeManager = await User.findOne({ + where: { + department: { + [Op.iLike]: '%finance%', + } as any, + role: 'MANAGEMENT' as any, + }, + order: [['createdAt', 'DESC']], + }); + if (financeManager) { + logger.info(`[DealerClaimService] Found finance manager: ${financeManager.email}`); + return financeManager; + } + + // Priority 2: Find user with designation containing "Finance" or "Accountant" + const financeUser = await User.findOne({ + where: { + [Op.or]: [ + { designation: { [Op.iLike]: '%finance%' } as any }, + { designation: { [Op.iLike]: '%accountant%' } as any }, + ], + }, + order: [['createdAt', 'DESC']], + }); + if (financeUser) { + logger.info(`[DealerClaimService] Found finance user by designation: ${financeUser.email}`); + return financeUser; + } + + // Priority 3: Check admin configurations for finance team email + const { getConfigValue } = await import('./configReader.service'); + const financeEmail = await getConfigValue('FINANCE_TEAM_EMAIL'); + if (financeEmail) { + const financeUserByEmail = await User.findOne({ + where: { email: financeEmail }, + }); + if (financeUserByEmail) { + logger.info(`[DealerClaimService] Found finance user from config: ${financeEmail}`); + return financeUserByEmail; + } + } + + logger.warn('[DealerClaimService] Could not resolve finance approver, will use default email'); + return null; + } catch (error) { + logger.error('[DealerClaimService] Error resolving finance approver:', error); + return null; + } + } + + /** + * Get claim details with all related data + */ + async getClaimDetails(requestId: string): Promise { + try { + const request = await WorkflowRequest.findByPk(requestId, { + include: [ + { model: User, as: 'initiator' }, + { model: ApprovalLevel, as: 'approvalLevels' }, + ] + }); + + if (!request) { + throw new Error('Request not found'); + } + + // Handle backward compatibility: workflowType may be undefined in old environments + const workflowType = request.workflowType || 'NON_TEMPLATIZED'; + if (workflowType !== 'CLAIM_MANAGEMENT') { + throw new Error('Request is not a claim management request'); + } + + // Fetch related claim data separately + const claimDetails = await DealerClaimDetails.findOne({ + where: { requestId } + }); + + const proposalDetails = await DealerProposalDetails.findOne({ + where: { requestId }, + include: [ + { + model: DealerProposalCostItem, + as: 'costItems', + required: false, + separate: true, // Use separate query for ordering + order: [['itemOrder', 'ASC']] + } + ] + }); + + const completionDetails = await DealerCompletionDetails.findOne({ + where: { requestId } + }); + + // Serialize claim details to ensure proper field names + let serializedClaimDetails = null; + if (claimDetails) { + serializedClaimDetails = (claimDetails as any).toJSON ? (claimDetails as any).toJSON() : claimDetails; + } + + // Transform proposal details to include cost items as array + let transformedProposalDetails = null; + if (proposalDetails) { + const proposalData = (proposalDetails as any).toJSON ? (proposalDetails as any).toJSON() : proposalDetails; + + // Get cost items from separate table (preferred) or fallback to JSONB + let costBreakup: any[] = []; + if (proposalData.costItems && Array.isArray(proposalData.costItems) && proposalData.costItems.length > 0) { + // Use cost items from separate table + costBreakup = proposalData.costItems.map((item: any) => ({ + description: item.itemDescription || item.description, + amount: Number(item.amount) || 0 + })); + } else if (proposalData.costBreakup) { + // Fallback to JSONB field for backward compatibility + if (Array.isArray(proposalData.costBreakup)) { + costBreakup = proposalData.costBreakup; + } else if (typeof proposalData.costBreakup === 'string') { + try { + costBreakup = JSON.parse(proposalData.costBreakup); + } catch (e) { + logger.warn('[DealerClaimService] Failed to parse costBreakup JSON:', e); + } + } + } + + transformedProposalDetails = { + ...proposalData, + costBreakup, // Always return as array for frontend compatibility + costItems: proposalData.costItems || [] // Also include raw cost items + }; + } + + // Serialize completion details + let serializedCompletionDetails = null; + if (completionDetails) { + serializedCompletionDetails = (completionDetails as any).toJSON ? (completionDetails as any).toJSON() : completionDetails; + } + + return { + request: (request as any).toJSON ? (request as any).toJSON() : request, + claimDetails: serializedClaimDetails, + proposalDetails: transformedProposalDetails, + completionDetails: serializedCompletionDetails, + }; + } catch (error) { + logger.error('[DealerClaimService] Error getting claim details:', error); + throw error; + } + } + + /** + * Submit dealer proposal (Step 1) + */ + async submitDealerProposal( + requestId: string, + proposalData: { + proposalDocumentPath?: string; + proposalDocumentUrl?: string; + costBreakup: any[]; + totalEstimatedBudget: number; + timelineMode: 'date' | 'days'; + expectedCompletionDate?: Date; + expectedCompletionDays?: number; + dealerComments: string; + } + ): Promise { + try { + const request = await WorkflowRequest.findByPk(requestId); + if (!request || request.workflowType !== 'CLAIM_MANAGEMENT') { + throw new Error('Invalid claim request'); + } + + if (request.currentLevel !== 1) { + throw new Error('Proposal can only be submitted at step 1'); + } + + // Save proposal details (keep costBreakup in JSONB for backward compatibility) + const [proposal] = await DealerProposalDetails.upsert({ + requestId, + proposalDocumentPath: proposalData.proposalDocumentPath, + proposalDocumentUrl: proposalData.proposalDocumentUrl, + // Keep costBreakup in JSONB for backward compatibility + costBreakup: proposalData.costBreakup.length > 0 ? proposalData.costBreakup : null, + totalEstimatedBudget: proposalData.totalEstimatedBudget, + timelineMode: proposalData.timelineMode, + expectedCompletionDate: proposalData.expectedCompletionDate, + expectedCompletionDays: proposalData.expectedCompletionDays, + dealerComments: proposalData.dealerComments, + submittedAt: new Date(), + }, { + returning: true + }); + + // Get proposalId - handle both Sequelize instance and plain object + let proposalId = (proposal as any).proposalId + || (proposal as any).proposal_id; + + // If not found, try getDataValue method + if (!proposalId && (proposal as any).getDataValue) { + proposalId = (proposal as any).getDataValue('proposalId'); + } + + // If still not found, fetch the proposal by requestId + if (!proposalId) { + const existingProposal = await DealerProposalDetails.findOne({ + where: { requestId } + }); + if (existingProposal) { + proposalId = (existingProposal as any).proposalId + || (existingProposal as any).proposal_id + || ((existingProposal as any).getDataValue ? (existingProposal as any).getDataValue('proposalId') : null); + } + } + + if (!proposalId) { + throw new Error('Failed to get proposal ID after saving proposal details'); + } + + // Save cost items to separate table (preferred approach) + if (proposalData.costBreakup && proposalData.costBreakup.length > 0) { + // Delete existing cost items for this proposal (in case of update) + await DealerProposalCostItem.destroy({ + where: { proposalId } + }); + + // Insert new cost items + const costItems = proposalData.costBreakup.map((item: any, index: number) => ({ + proposalId, + requestId, + itemDescription: item.description || item.itemDescription || '', + amount: Number(item.amount) || 0, + itemOrder: index + })); + + await DealerProposalCostItem.bulkCreate(costItems); + logger.info(`[DealerClaimService] Saved ${costItems.length} cost items for proposal ${proposalId}`); + } + + // Update estimated budget in claim details + await DealerClaimDetails.update( + { estimatedBudget: proposalData.totalEstimatedBudget }, + { where: { requestId } } + ); + + // Approve step 1 and move to step 2 + const level1 = await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 1 } + }); + + if (level1) { + await this.approvalService.approveLevel( + level1.levelId, + { action: 'APPROVE', comments: 'Dealer proposal submitted' }, + 'system', // System approval + { ipAddress: null, userAgent: null } + ); + } + + logger.info(`[DealerClaimService] Dealer proposal submitted for request: ${requestId}`); + } catch (error) { + logger.error('[DealerClaimService] Error submitting dealer proposal:', error); + throw error; + } + } + + /** + * Submit dealer completion documents (Step 5) + */ + async submitCompletionDocuments( + requestId: string, + completionData: { + activityCompletionDate: Date; + numberOfParticipants?: number; + closedExpenses: any[]; + totalClosedExpenses: number; + completionDocuments: any[]; + activityPhotos: any[]; + } + ): Promise { + try { + const request = await WorkflowRequest.findByPk(requestId); + // Handle backward compatibility: workflowType may be undefined in old environments + const workflowType = request?.workflowType || 'NON_TEMPLATIZED'; + if (!request || workflowType !== 'CLAIM_MANAGEMENT') { + throw new Error('Invalid claim request'); + } + + if (request.currentLevel !== 5) { + throw new Error('Completion documents can only be submitted at step 5'); + } + + // Save completion details + await DealerCompletionDetails.upsert({ + requestId, + activityCompletionDate: completionData.activityCompletionDate, + numberOfParticipants: completionData.numberOfParticipants, + closedExpenses: completionData.closedExpenses, + totalClosedExpenses: completionData.totalClosedExpenses, + completionDocuments: completionData.completionDocuments, + activityPhotos: completionData.activityPhotos, + submittedAt: new Date(), + }); + + // Update closed expenses in claim details + await DealerClaimDetails.update( + { closedExpenses: completionData.totalClosedExpenses }, + { where: { requestId } } + ); + + // Approve step 5 and move to step 6 + const level5 = await ApprovalLevel.findOne({ + where: { requestId, levelNumber: 5 } + }); + + if (level5) { + await this.approvalService.approveLevel( + level5.levelId, + { action: 'APPROVE', comments: 'Completion documents submitted' }, + 'system', + { ipAddress: null, userAgent: null } + ); + } + + logger.info(`[DealerClaimService] Completion documents submitted for request: ${requestId}`); + } catch (error) { + logger.error('[DealerClaimService] Error submitting completion documents:', error); + throw error; + } + } + + /** + * Update IO details (Step 3 - Department Lead) + * Validates IO number with SAP and blocks budget + */ + async updateIODetails( + requestId: string, + ioData: { + ioNumber: string; + availableBalance?: number; + blockedAmount?: number; + remainingBalance?: number; + } + ): Promise { + try { + // Validate IO number with SAP + const ioValidation = await sapIntegrationService.validateIONumber(ioData.ioNumber); + + if (!ioValidation.isValid) { + throw new Error(`Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`); + } + + // If amount is provided, block budget in SAP + let blockedAmount = ioData.blockedAmount || 0; + let remainingBalance = ioValidation.remainingBalance; + + if (ioData.blockedAmount && ioData.blockedAmount > 0) { + const request = await WorkflowRequest.findByPk(requestId); + const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; + + const blockResult = await sapIntegrationService.blockBudget( + ioData.ioNumber, + ioData.blockedAmount, + requestNumber, + `Budget block for claim request ${requestNumber}` + ); + + if (!blockResult.success) { + throw new Error(`Failed to block budget in SAP: ${blockResult.error}`); + } + + blockedAmount = blockResult.blockedAmount; + remainingBalance = blockResult.remainingBalance; + } + + // Update claim details with IO information + await DealerClaimDetails.update( + { + ioNumber: ioData.ioNumber, + ioAvailableBalance: ioValidation.availableBalance, + ioBlockedAmount: blockedAmount, + ioRemainingBalance: remainingBalance, + }, + { where: { requestId } } + ); + + logger.info(`[DealerClaimService] IO details updated for request: ${requestId}`, { + ioNumber: ioData.ioNumber, + blockedAmount, + remainingBalance + }); + } catch (error) { + logger.error('[DealerClaimService] Error updating IO details:', error); + throw error; + } + } + + /** + * Update e-invoice details (Step 7) + * Generates e-invoice via DMS integration + */ + async updateEInvoiceDetails( + requestId: string, + invoiceData?: { + eInvoiceNumber?: string; + eInvoiceDate?: Date; + dmsNumber?: string; + amount?: number; + description?: string; + } + ): Promise { + try { + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (!claimDetails) { + throw new Error('Claim details not found'); + } + + const request = await WorkflowRequest.findByPk(requestId); + const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; + + // If invoice data not provided, generate via DMS + if (!invoiceData?.eInvoiceNumber) { + const proposalDetails = await DealerProposalDetails.findOne({ where: { requestId } }); + const invoiceAmount = invoiceData?.amount || proposalDetails?.totalEstimatedBudget || claimDetails.estimatedBudget || 0; + + const invoiceResult = await dmsIntegrationService.generateEInvoice({ + requestNumber, + dealerCode: claimDetails.dealerCode, + dealerName: claimDetails.dealerName, + amount: invoiceAmount, + description: invoiceData?.description || `E-Invoice for claim request ${requestNumber}`, + ioNumber: claimDetails.ioNumber || undefined, + }); + + if (!invoiceResult.success) { + throw new Error(`Failed to generate e-invoice: ${invoiceResult.error}`); + } + + await DealerClaimDetails.update( + { + eInvoiceNumber: invoiceResult.eInvoiceNumber, + eInvoiceDate: invoiceResult.invoiceDate || new Date(), + dmsNumber: invoiceResult.dmsNumber, + }, + { where: { requestId } } + ); + + logger.info(`[DealerClaimService] E-Invoice generated via DMS for request: ${requestId}`, { + eInvoiceNumber: invoiceResult.eInvoiceNumber, + dmsNumber: invoiceResult.dmsNumber + }); + } else { + // Manual entry - just update the fields + await DealerClaimDetails.update( + { + eInvoiceNumber: invoiceData.eInvoiceNumber, + eInvoiceDate: invoiceData.eInvoiceDate || new Date(), + dmsNumber: invoiceData.dmsNumber, + }, + { where: { requestId } } + ); + + logger.info(`[DealerClaimService] E-Invoice details manually updated for request: ${requestId}`); + } + } catch (error) { + logger.error('[DealerClaimService] Error updating e-invoice details:', error); + throw error; + } + } + + /** + * Update credit note details (Step 8) + * Generates credit note via DMS integration + */ + async updateCreditNoteDetails( + requestId: string, + creditNoteData?: { + creditNoteNumber?: string; + creditNoteDate?: Date; + creditNoteAmount?: number; + reason?: string; + description?: string; + } + ): Promise { + try { + const claimDetails = await DealerClaimDetails.findOne({ where: { requestId } }); + if (!claimDetails) { + throw new Error('Claim details not found'); + } + + if (!claimDetails.eInvoiceNumber) { + throw new Error('E-Invoice must be generated before creating credit note'); + } + + const request = await WorkflowRequest.findByPk(requestId); + const requestNumber = request ? ((request as any).requestNumber || (request as any).request_number) : 'UNKNOWN'; + + // If credit note data not provided, generate via DMS + if (!creditNoteData?.creditNoteNumber) { + const creditNoteAmount = creditNoteData?.creditNoteAmount || claimDetails.closedExpenses || 0; + + const creditNoteResult = await dmsIntegrationService.generateCreditNote({ + requestNumber, + eInvoiceNumber: claimDetails.eInvoiceNumber, + dealerCode: claimDetails.dealerCode, + dealerName: claimDetails.dealerName, + amount: creditNoteAmount, + reason: creditNoteData?.reason || 'Claim settlement', + description: creditNoteData?.description || `Credit note for claim request ${requestNumber}`, + }); + + if (!creditNoteResult.success) { + throw new Error(`Failed to generate credit note: ${creditNoteResult.error}`); + } + + await DealerClaimDetails.update( + { + creditNoteNumber: creditNoteResult.creditNoteNumber, + creditNoteDate: creditNoteResult.creditNoteDate || new Date(), + creditNoteAmount: creditNoteResult.creditNoteAmount, + }, + { where: { requestId } } + ); + + logger.info(`[DealerClaimService] Credit note generated via DMS for request: ${requestId}`, { + creditNoteNumber: creditNoteResult.creditNoteNumber, + creditNoteAmount: creditNoteResult.creditNoteAmount + }); + } else { + // Manual entry - just update the fields + await DealerClaimDetails.update( + { + creditNoteNumber: creditNoteData.creditNoteNumber, + creditNoteDate: creditNoteData.creditNoteDate || new Date(), + creditNoteAmount: creditNoteData.creditNoteAmount, + }, + { where: { requestId } } + ); + + logger.info(`[DealerClaimService] Credit note details manually updated for request: ${requestId}`); + } + } catch (error) { + logger.error('[DealerClaimService] Error updating credit note details:', error); + throw error; + } + } +} + diff --git a/src/services/dmsIntegration.service.ts b/src/services/dmsIntegration.service.ts new file mode 100644 index 0000000..b990e2d --- /dev/null +++ b/src/services/dmsIntegration.service.ts @@ -0,0 +1,313 @@ +import logger from '../utils/logger'; + +/** + * DMS (Document Management System) Integration Service + * Handles integration with DMS for e-invoice and credit note generation + * + * NOTE: This is a placeholder/stub implementation. + * Replace with actual DMS API integration based on your DMS system. + */ +export class DMSIntegrationService { + private dmsBaseUrl: string; + private dmsApiKey?: string; + private dmsUsername?: string; + private dmsPassword?: string; + + constructor() { + this.dmsBaseUrl = process.env.DMS_BASE_URL || ''; + this.dmsApiKey = process.env.DMS_API_KEY; + this.dmsUsername = process.env.DMS_USERNAME; + this.dmsPassword = process.env.DMS_PASSWORD; + } + + /** + * Check if DMS integration is configured + */ + private isConfigured(): boolean { + return !!this.dmsBaseUrl && (!!this.dmsApiKey || (!!this.dmsUsername && !!this.dmsPassword)); + } + + /** + * Generate e-invoice in DMS + * @param invoiceData - Invoice data + * @returns E-invoice details including invoice number, DMS number, etc. + */ + async generateEInvoice(invoiceData: { + requestNumber: string; + dealerCode: string; + dealerName: string; + amount: number; + description: string; + ioNumber?: string; + taxDetails?: any; + }): Promise<{ + success: boolean; + eInvoiceNumber?: string; + dmsNumber?: string; + invoiceDate?: Date; + invoiceUrl?: string; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[DMS] DMS integration not configured, generating mock e-invoice'); + // Return mock data for development/testing + const mockInvoiceNumber = `EINV-${Date.now()}`; + const mockDmsNumber = `DMS-${Date.now()}`; + return { + success: true, + eInvoiceNumber: mockInvoiceNumber, + dmsNumber: mockDmsNumber, + invoiceDate: new Date(), + invoiceUrl: `https://dms.example.com/invoices/${mockInvoiceNumber}`, + error: 'DMS not configured - e-invoice generation simulated' + }; + } + + // TODO: Implement actual DMS API call + // Example: + // const response = await axios.post(`${this.dmsBaseUrl}/api/invoices/generate`, { + // request_number: invoiceData.requestNumber, + // dealer_code: invoiceData.dealerCode, + // dealer_name: invoiceData.dealerName, + // amount: invoiceData.amount, + // description: invoiceData.description, + // io_number: invoiceData.ioNumber, + // tax_details: invoiceData.taxDetails + // }, { + // headers: { + // 'Authorization': `Bearer ${this.dmsApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // success: response.data.success, + // eInvoiceNumber: response.data.e_invoice_number, + // dmsNumber: response.data.dms_number, + // invoiceDate: new Date(response.data.invoice_date), + // invoiceUrl: response.data.invoice_url + // }; + + logger.warn('[DMS] DMS e-invoice generation not implemented, generating mock invoice'); + const mockInvoiceNumber = `EINV-${Date.now()}`; + const mockDmsNumber = `DMS-${Date.now()}`; + return { + success: true, + eInvoiceNumber: mockInvoiceNumber, + dmsNumber: mockDmsNumber, + invoiceDate: new Date(), + invoiceUrl: `https://dms.example.com/invoices/${mockInvoiceNumber}`, + error: 'DMS API not implemented - e-invoice generation simulated' + }; + } catch (error) { + logger.error('[DMS] Error generating e-invoice:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Generate credit note in DMS + * @param creditNoteData - Credit note data + * @returns Credit note details including credit note number, amount, etc. + */ + async generateCreditNote(creditNoteData: { + requestNumber: string; + eInvoiceNumber: string; + dealerCode: string; + dealerName: string; + amount: number; + reason: string; + description?: string; + }): Promise<{ + success: boolean; + creditNoteNumber?: string; + creditNoteDate?: Date; + creditNoteAmount?: number; + creditNoteUrl?: string; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[DMS] DMS integration not configured, generating mock credit note'); + // Return mock data for development/testing + const mockCreditNoteNumber = `CN-${Date.now()}`; + return { + success: true, + creditNoteNumber: mockCreditNoteNumber, + creditNoteDate: new Date(), + creditNoteAmount: creditNoteData.amount, + creditNoteUrl: `https://dms.example.com/credit-notes/${mockCreditNoteNumber}`, + error: 'DMS not configured - credit note generation simulated' + }; + } + + // TODO: Implement actual DMS API call + // Example: + // const response = await axios.post(`${this.dmsBaseUrl}/api/credit-notes/generate`, { + // request_number: creditNoteData.requestNumber, + // e_invoice_number: creditNoteData.eInvoiceNumber, + // dealer_code: creditNoteData.dealerCode, + // dealer_name: creditNoteData.dealerName, + // amount: creditNoteData.amount, + // reason: creditNoteData.reason, + // description: creditNoteData.description + // }, { + // headers: { + // 'Authorization': `Bearer ${this.dmsApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // success: response.data.success, + // creditNoteNumber: response.data.credit_note_number, + // creditNoteDate: new Date(response.data.credit_note_date), + // creditNoteAmount: response.data.credit_note_amount, + // creditNoteUrl: response.data.credit_note_url + // }; + + logger.warn('[DMS] DMS credit note generation not implemented, generating mock credit note'); + const mockCreditNoteNumber = `CN-${Date.now()}`; + return { + success: true, + creditNoteNumber: mockCreditNoteNumber, + creditNoteDate: new Date(), + creditNoteAmount: creditNoteData.amount, + creditNoteUrl: `https://dms.example.com/credit-notes/${mockCreditNoteNumber}`, + error: 'DMS API not implemented - credit note generation simulated' + }; + } catch (error) { + logger.error('[DMS] Error generating credit note:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Get invoice status from DMS + * @param eInvoiceNumber - E-invoice number + * @returns Invoice status and details + */ + async getInvoiceStatus(eInvoiceNumber: string): Promise<{ + success: boolean; + status?: string; + invoiceNumber?: string; + dmsNumber?: string; + invoiceDate?: Date; + amount?: number; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[DMS] DMS integration not configured, returning mock invoice status'); + return { + success: true, + status: 'GENERATED', + invoiceNumber: eInvoiceNumber, + dmsNumber: `DMS-${Date.now()}`, + invoiceDate: new Date(), + amount: 0, + error: 'DMS not configured - invoice status simulated' + }; + } + + // TODO: Implement actual DMS API call + // Example: + // const response = await axios.get(`${this.dmsBaseUrl}/api/invoices/${eInvoiceNumber}/status`, { + // headers: { + // 'Authorization': `Bearer ${this.dmsApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // success: true, + // status: response.data.status, + // invoiceNumber: response.data.invoice_number, + // dmsNumber: response.data.dms_number, + // invoiceDate: new Date(response.data.invoice_date), + // amount: response.data.amount + // }; + + logger.warn('[DMS] DMS invoice status check not implemented, returning mock status'); + return { + success: true, + status: 'GENERATED', + invoiceNumber: eInvoiceNumber, + dmsNumber: `DMS-${Date.now()}`, + invoiceDate: new Date(), + amount: 0, + error: 'DMS API not implemented - invoice status simulated' + }; + } catch (error) { + logger.error('[DMS] Error getting invoice status:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Download invoice document from DMS + * @param eInvoiceNumber - E-invoice number + * @returns Invoice document URL or file buffer + */ + async downloadInvoice(eInvoiceNumber: string): Promise<{ + success: boolean; + documentUrl?: string; + documentBuffer?: Buffer; + mimeType?: string; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[DMS] DMS integration not configured, returning mock download URL'); + return { + success: true, + documentUrl: `https://dms.example.com/invoices/${eInvoiceNumber}/download`, + mimeType: 'application/pdf', + error: 'DMS not configured - download URL simulated' + }; + } + + // TODO: Implement actual DMS API call + // Example: + // const response = await axios.get(`${this.dmsBaseUrl}/api/invoices/${eInvoiceNumber}/download`, { + // headers: { + // 'Authorization': `Bearer ${this.dmsApiKey}` + // }, + // responseType: 'arraybuffer' + // }); + // + // return { + // success: true, + // documentBuffer: Buffer.from(response.data), + // mimeType: response.headers['content-type'] || 'application/pdf' + // }; + + logger.warn('[DMS] DMS invoice download not implemented, returning mock URL'); + return { + success: true, + documentUrl: `https://dms.example.com/invoices/${eInvoiceNumber}/download`, + mimeType: 'application/pdf', + error: 'DMS API not implemented - download URL simulated' + }; + } catch (error) { + logger.error('[DMS] Error downloading invoice:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } +} + +export const dmsIntegrationService = new DMSIntegrationService(); + diff --git a/src/services/enhancedTemplate.service.ts b/src/services/enhancedTemplate.service.ts new file mode 100644 index 0000000..813f152 --- /dev/null +++ b/src/services/enhancedTemplate.service.ts @@ -0,0 +1,241 @@ +import { WorkflowTemplate } from '../models/WorkflowTemplate'; +import { WorkflowRequest } from '../models/WorkflowRequest'; +import { ApprovalLevel } from '../models/ApprovalLevel'; +import { TemplateFieldResolver, FormStepConfig } from './templateFieldResolver.service'; +import logger from '../utils/logger'; + +/** + * Enhanced Template Service + * Handles template-based workflow operations with dynamic form configuration + */ +export class EnhancedTemplateService { + private fieldResolver = new TemplateFieldResolver(); + + /** + * Get form configuration for a template with resolved user references + */ + async getFormConfig( + templateId: string, + requestId?: string, + currentUserId?: string + ): Promise { + try { + const template = await WorkflowTemplate.findByPk(templateId); + if (!template) { + throw new Error('Template not found'); + } + + const stepsConfig = (template.formStepsConfig || []) as FormStepConfig[]; + + // If request exists, resolve user references + if (requestId && currentUserId) { + const request = await WorkflowRequest.findByPk(requestId, { + include: [{ model: ApprovalLevel, as: 'approvalLevels' }] + }); + if (request) { + return await this.resolveStepsWithUserData(stepsConfig, request, currentUserId); + } + } + + return stepsConfig; + } catch (error) { + logger.error('[EnhancedTemplateService] Error getting form config:', error); + throw error; + } + } + + /** + * Resolve user references in all steps + */ + private async resolveStepsWithUserData( + steps: FormStepConfig[], + request: WorkflowRequest, + currentUserId: string + ): Promise { + try { + // Get all approvers for context + const approvers = await ApprovalLevel.findAll({ + where: { requestId: request.requestId } + }); + const approverMap = new Map( + approvers.map(a => [a.levelNumber, a]) + ); + + const resolvedSteps = await Promise.all( + steps.map(async (step) => { + const resolvedFields = await this.fieldResolver.resolveUserReferences( + step, + request, + currentUserId, + { + currentLevel: request.currentLevel, + approvers: approverMap + } + ); + + // Merge resolved values into field defaults + const enrichedFields = step.fields.map(field => ({ + ...field, + defaultValue: resolvedFields[field.fieldId] || field.defaultValue + })); + + return { + ...step, + fields: enrichedFields + }; + }) + ); + + return resolvedSteps; + } catch (error) { + logger.error('[EnhancedTemplateService] Error resolving steps:', error); + return steps; // Return original steps on error + } + } + + /** + * Validate and save form data for a step + */ + async saveStepData( + templateId: string, + requestId: string, + stepNumber: number, + formData: Record, + userId: string + ): Promise { + try { + const template = await WorkflowTemplate.findByPk(templateId); + if (!template) { + throw new Error('Template not found'); + } + + const stepsConfig = (template.formStepsConfig || []) as FormStepConfig[]; + const stepConfig = stepsConfig.find(s => s.stepNumber === stepNumber); + + if (!stepConfig) { + throw new Error(`Step ${stepNumber} not found in template`); + } + + // Validate required fields + this.validateStepData(stepConfig, formData); + + // Save to template-specific storage + await this.saveToTemplateStorage(template.workflowType, requestId, stepNumber, formData); + } catch (error) { + logger.error('[EnhancedTemplateService] Error saving step data:', error); + throw error; + } + } + + /** + * Validate step data against configuration + */ + private validateStepData(stepConfig: FormStepConfig, formData: Record): void { + for (const field of stepConfig.fields) { + if (field.required && !formData[field.fieldId]) { + throw new Error(`Field ${field.label} is required`); + } + + // Apply validation rules + if (field.validation && formData[field.fieldId]) { + const value = formData[field.fieldId]; + if (field.validation.min !== undefined && value < field.validation.min) { + throw new Error(`${field.label} must be at least ${field.validation.min}`); + } + if (field.validation.max !== undefined && value > field.validation.max) { + throw new Error(`${field.label} must be at most ${field.validation.max}`); + } + if (field.validation.pattern) { + const regex = new RegExp(field.validation.pattern); + if (!regex.test(String(value))) { + throw new Error(`${field.label} format is invalid`); + } + } + } + } + } + + /** + * Save to template-specific storage based on workflow type + */ + private async saveToTemplateStorage( + workflowType: string, + requestId: string, + stepNumber: number, + formData: Record + ): Promise { + switch (workflowType) { + case 'CLAIM_MANAGEMENT': + await this.saveClaimManagementStepData(requestId, stepNumber, formData); + break; + default: + // Generic storage for custom templates + logger.warn(`[EnhancedTemplateService] No specific storage handler for workflow type: ${workflowType}`); + } + } + + /** + * Save claim management step data + */ + private async saveClaimManagementStepData( + requestId: string, + stepNumber: number, + formData: Record + ): Promise { + const { DealerClaimDetails } = await import('../models/DealerClaimDetails'); + const { DealerProposalDetails } = await import('../models/DealerProposalDetails'); + const { DealerCompletionDetails } = await import('../models/DealerCompletionDetails'); + + switch (stepNumber) { + case 1: + // Save to dealer_claim_details + await DealerClaimDetails.upsert({ + requestId, + activityName: formData.activity_name, + activityType: formData.activity_type, + dealerCode: formData.dealer_code, + dealerName: formData.dealer_name, + dealerEmail: formData.dealer_email, + dealerPhone: formData.dealer_phone, + dealerAddress: formData.dealer_address, + activityDate: formData.activity_date, + location: formData.location, + periodStartDate: formData.period_start_date, + periodEndDate: formData.period_end_date, + estimatedBudget: formData.estimated_budget, + }); + break; + case 2: + // Save to dealer_proposal_details + await DealerProposalDetails.upsert({ + requestId, + costBreakup: formData.cost_breakup, + totalEstimatedBudget: formData.total_estimated_budget, + timelineMode: formData.timeline_mode, + expectedCompletionDate: formData.expected_completion_date, + expectedCompletionDays: formData.expected_completion_days, + dealerComments: formData.dealer_comments, + proposalDocumentPath: formData.proposal_document_path, + proposalDocumentUrl: formData.proposal_document_url, + submittedAt: new Date(), + }); + break; + case 5: + // Save to dealer_completion_details + await DealerCompletionDetails.upsert({ + requestId, + activityCompletionDate: formData.activity_completion_date, + numberOfParticipants: formData.number_of_participants, + closedExpenses: formData.closed_expenses, + totalClosedExpenses: formData.total_closed_expenses, + completionDocuments: formData.completion_documents, + activityPhotos: formData.activity_photos, + submittedAt: new Date(), + }); + break; + default: + logger.warn(`[EnhancedTemplateService] No storage handler for claim management step ${stepNumber}`); + } + } +} + diff --git a/src/services/pause.service.ts b/src/services/pause.service.ts index b910ad0..d2521f2 100644 --- a/src/services/pause.service.ts +++ b/src/services/pause.service.ts @@ -657,14 +657,44 @@ export class PauseService { const now = new Date(); // Find all paused workflows where resume date has passed - const pausedWorkflows = await WorkflowRequest.findAll({ - where: { - isPaused: true, - pauseResumeDate: { - [Op.lte]: now + // Handle backward compatibility: workflow_type column may not exist in old environments + let pausedWorkflows: WorkflowRequest[]; + try { + pausedWorkflows = await WorkflowRequest.findAll({ + where: { + isPaused: true, + pauseResumeDate: { + [Op.lte]: now + } } + }); + } catch (error: any) { + // If error is due to missing workflow_type column, use raw query + if (error.message?.includes('workflow_type') || (error.message?.includes('column') && error.message?.includes('does not exist'))) { + logger.warn('[Pause] workflow_type column not found, using raw query for backward compatibility'); + const { sequelize } = await import('../config/database'); + const { QueryTypes } = await import('sequelize'); + const results = await sequelize.query(` + SELECT request_id, is_paused, pause_resume_date + FROM workflow_requests + WHERE is_paused = true + AND pause_resume_date <= :now + `, { + replacements: { now }, + type: QueryTypes.SELECT + }); + + // Convert to WorkflowRequest-like objects + // results is an array of objects from SELECT query + pausedWorkflows = (results as any[]).map((r: any) => ({ + requestId: r.request_id, + isPaused: r.is_paused, + pauseResumeDate: r.pause_resume_date + })) as any; + } else { + throw error; // Re-throw if it's a different error } - }); + } let resumedCount = 0; for (const workflow of pausedWorkflows) { diff --git a/src/services/sapIntegration.service.ts b/src/services/sapIntegration.service.ts new file mode 100644 index 0000000..c4c539b --- /dev/null +++ b/src/services/sapIntegration.service.ts @@ -0,0 +1,298 @@ +import logger from '../utils/logger'; + +/** + * SAP Integration Service + * Handles integration with SAP for IO validation and budget blocking + * + * NOTE: This is a placeholder/stub implementation. + * Replace with actual SAP API integration based on your SAP system. + */ +export class SAPIntegrationService { + private sapBaseUrl: string; + private sapApiKey?: string; + private sapUsername?: string; + private sapPassword?: string; + + constructor() { + this.sapBaseUrl = process.env.SAP_BASE_URL || ''; + this.sapApiKey = process.env.SAP_API_KEY; + this.sapUsername = process.env.SAP_USERNAME; + this.sapPassword = process.env.SAP_PASSWORD; + } + + /** + * Check if SAP integration is configured + */ + private isConfigured(): boolean { + return !!this.sapBaseUrl && (!!this.sapApiKey || (!!this.sapUsername && !!this.sapPassword)); + } + + /** + * Validate IO number and get IO details + * @param ioNumber - IO (Internal Order) number from SAP + * @returns IO details including available balance, blocked amount, etc. + */ + async validateIONumber(ioNumber: string): Promise<{ + isValid: boolean; + ioNumber: string; + availableBalance: number; + blockedAmount: number; + remainingBalance: number; + currency: string; + description?: string; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[SAP] SAP integration not configured, returning mock data'); + // Return mock data for development/testing + return { + isValid: true, + ioNumber, + availableBalance: 1000000, + blockedAmount: 0, + remainingBalance: 1000000, + currency: 'INR', + description: 'Mock IO Data (SAP not configured)' + }; + } + + // TODO: Implement actual SAP API call + // Example: + // const response = await axios.get(`${this.sapBaseUrl}/api/io/${ioNumber}`, { + // headers: { + // 'Authorization': `Bearer ${this.sapApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // isValid: response.data.valid, + // ioNumber: response.data.io_number, + // availableBalance: response.data.available_balance, + // blockedAmount: response.data.blocked_amount, + // remainingBalance: response.data.remaining_balance, + // currency: response.data.currency, + // description: response.data.description + // }; + + logger.warn('[SAP] SAP API integration not implemented, returning mock data'); + return { + isValid: true, + ioNumber, + availableBalance: 1000000, + blockedAmount: 0, + remainingBalance: 1000000, + currency: 'INR', + description: 'Mock IO Data (SAP API not implemented)' + }; + } catch (error) { + logger.error('[SAP] Error validating IO number:', error); + return { + isValid: false, + ioNumber, + availableBalance: 0, + blockedAmount: 0, + remainingBalance: 0, + currency: 'INR', + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Block budget in SAP for a claim request + * @param ioNumber - IO number + * @param amount - Amount to block + * @param requestNumber - Request number for reference + * @param description - Description of the block + * @returns Blocking confirmation details + */ + async blockBudget( + ioNumber: string, + amount: number, + requestNumber: string, + description?: string + ): Promise<{ + success: boolean; + blockId?: string; + blockedAmount: number; + remainingBalance: number; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[SAP] SAP integration not configured, budget blocking skipped'); + return { + success: true, + blockedAmount: amount, + remainingBalance: 1000000 - amount, + error: 'SAP not configured - budget blocking simulated' + }; + } + + // TODO: Implement actual SAP API call to block budget + // Example: + // const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/block`, { + // amount, + // reference: requestNumber, + // description: description || `Budget block for request ${requestNumber}` + // }, { + // headers: { + // 'Authorization': `Bearer ${this.sapApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // success: response.data.success, + // blockId: response.data.block_id, + // blockedAmount: response.data.blocked_amount, + // remainingBalance: response.data.remaining_balance + // }; + + logger.warn('[SAP] SAP budget blocking not implemented, simulating block'); + return { + success: true, + blockId: `BLOCK-${Date.now()}`, + blockedAmount: amount, + remainingBalance: 1000000 - amount, + error: 'SAP API not implemented - budget blocking simulated' + }; + } catch (error) { + logger.error('[SAP] Error blocking budget:', error); + return { + success: false, + blockedAmount: 0, + remainingBalance: 0, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Release blocked budget in SAP + * @param ioNumber - IO number + * @param blockId - Block ID from previous block operation + * @param requestNumber - Request number for reference + * @returns Release confirmation + */ + async releaseBudget( + ioNumber: string, + blockId: string, + requestNumber: string + ): Promise<{ + success: boolean; + releasedAmount: number; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[SAP] SAP integration not configured, budget release skipped'); + return { + success: true, + releasedAmount: 0, + error: 'SAP not configured - budget release simulated' + }; + } + + // TODO: Implement actual SAP API call to release budget + // Example: + // const response = await axios.post(`${this.sapBaseUrl}/api/io/${ioNumber}/release`, { + // block_id: blockId, + // reference: requestNumber + // }, { + // headers: { + // 'Authorization': `Bearer ${this.sapApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // success: response.data.success, + // releasedAmount: response.data.released_amount + // }; + + logger.warn('[SAP] SAP budget release not implemented, simulating release'); + return { + success: true, + releasedAmount: 0, + error: 'SAP API not implemented - budget release simulated' + }; + } catch (error) { + logger.error('[SAP] Error releasing budget:', error); + return { + success: false, + releasedAmount: 0, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + /** + * Get dealer information from SAP + * @param dealerCode - Dealer code + * @returns Dealer details from SAP + */ + async getDealerInfo(dealerCode: string): Promise<{ + isValid: boolean; + dealerCode: string; + dealerName?: string; + dealerEmail?: string; + dealerPhone?: string; + dealerAddress?: string; + error?: string; + }> { + try { + if (!this.isConfigured()) { + logger.warn('[SAP] SAP integration not configured, returning mock dealer data'); + return { + isValid: true, + dealerCode, + dealerName: `Dealer ${dealerCode}`, + dealerEmail: `dealer${dealerCode}@example.com`, + dealerPhone: '+91-XXXXXXXXXX', + dealerAddress: 'Mock Address' + }; + } + + // TODO: Implement actual SAP API call to get dealer info + // Example: + // const response = await axios.get(`${this.sapBaseUrl}/api/dealers/${dealerCode}`, { + // headers: { + // 'Authorization': `Bearer ${this.sapApiKey}`, + // 'Content-Type': 'application/json' + // } + // }); + // + // return { + // isValid: response.data.valid, + // dealerCode: response.data.dealer_code, + // dealerName: response.data.dealer_name, + // dealerEmail: response.data.dealer_email, + // dealerPhone: response.data.dealer_phone, + // dealerAddress: response.data.dealer_address + // }; + + logger.warn('[SAP] SAP dealer lookup not implemented, returning mock data'); + return { + isValid: true, + dealerCode, + dealerName: `Dealer ${dealerCode}`, + dealerEmail: `dealer${dealerCode}@example.com`, + dealerPhone: '+91-XXXXXXXXXX', + dealerAddress: 'Mock Address' + }; + } catch (error) { + logger.error('[SAP] Error getting dealer info:', error); + return { + isValid: false, + dealerCode, + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } +} + +export const sapIntegrationService = new SAPIntegrationService(); + diff --git a/src/services/template.service.ts b/src/services/template.service.ts new file mode 100644 index 0000000..dd71d1f --- /dev/null +++ b/src/services/template.service.ts @@ -0,0 +1,246 @@ +import { WorkflowTemplate } from '../models/WorkflowTemplate'; +import { WorkflowRequest } from '../models/WorkflowRequest'; +import { User } from '../models/User'; +import { Op } from 'sequelize'; +import logger from '../utils/logger'; + +/** + * Template Service + * Handles CRUD operations for workflow templates + */ +export class TemplateService { + /** + * Create a new workflow template + */ + async createTemplate( + userId: string, + templateData: { + templateName: string; + templateCode?: string; + templateDescription?: string; + templateCategory?: string; + workflowType?: string; + approvalLevelsConfig?: any; + defaultTatHours?: number; + formStepsConfig?: any; + userFieldMappings?: any; + dynamicApproverConfig?: any; + isActive?: boolean; + } + ): Promise { + try { + // Validate template code uniqueness if provided + if (templateData.templateCode) { + const existing = await WorkflowTemplate.findOne({ + where: { templateCode: templateData.templateCode } + }); + if (existing) { + throw new Error(`Template code '${templateData.templateCode}' already exists`); + } + } + + const template = await WorkflowTemplate.create({ + templateName: templateData.templateName, + templateCode: templateData.templateCode, + templateDescription: templateData.templateDescription, + templateCategory: templateData.templateCategory, + workflowType: templateData.workflowType || templateData.templateCode?.toUpperCase(), + approvalLevelsConfig: templateData.approvalLevelsConfig, + defaultTatHours: templateData.defaultTatHours || 24, + formStepsConfig: templateData.formStepsConfig, + userFieldMappings: templateData.userFieldMappings, + dynamicApproverConfig: templateData.dynamicApproverConfig, + isActive: templateData.isActive !== undefined ? templateData.isActive : true, + isSystemTemplate: false, // Admin-created templates are not system templates + usageCount: 0, + createdBy: userId, + }); + + logger.info(`[TemplateService] Created template: ${template.templateId}`); + return template; + } catch (error) { + logger.error('[TemplateService] Error creating template:', error); + throw error; + } + } + + /** + * Get template by ID + */ + async getTemplate(templateId: string): Promise { + try { + return await WorkflowTemplate.findByPk(templateId, { + include: [{ model: User, as: 'creator' }] + }); + } catch (error) { + logger.error('[TemplateService] Error getting template:', error); + throw error; + } + } + + /** + * Get template by code + */ + async getTemplateByCode(templateCode: string): Promise { + try { + return await WorkflowTemplate.findOne({ + where: { templateCode }, + include: [{ model: User, as: 'creator' }] + }); + } catch (error) { + logger.error('[TemplateService] Error getting template by code:', error); + throw error; + } + } + + /** + * List all templates with filters + */ + async listTemplates(filters?: { + category?: string; + workflowType?: string; + isActive?: boolean; + isSystemTemplate?: boolean; + search?: string; + }): Promise { + try { + const where: any = {}; + + if (filters?.category) { + where.templateCategory = filters.category; + } + + if (filters?.workflowType) { + where.workflowType = filters.workflowType; + } + + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; + } + + if (filters?.isSystemTemplate !== undefined) { + where.isSystemTemplate = filters.isSystemTemplate; + } + + if (filters?.search) { + where[Op.or] = [ + { templateName: { [Op.iLike]: `%${filters.search}%` } }, + { templateCode: { [Op.iLike]: `%${filters.search}%` } }, + { templateDescription: { [Op.iLike]: `%${filters.search}%` } } + ]; + } + + return await WorkflowTemplate.findAll({ + where, + include: [{ model: User, as: 'creator' }], + order: [['createdAt', 'DESC']] + }); + } catch (error) { + logger.error('[TemplateService] Error listing templates:', error); + throw error; + } + } + + /** + * Update template + */ + async updateTemplate( + templateId: string, + userId: string, + updateData: { + templateName?: string; + templateDescription?: string; + templateCategory?: string; + approvalLevelsConfig?: any; + defaultTatHours?: number; + formStepsConfig?: any; + userFieldMappings?: any; + dynamicApproverConfig?: any; + isActive?: boolean; + } + ): Promise { + try { + const template = await WorkflowTemplate.findByPk(templateId); + if (!template) { + throw new Error('Template not found'); + } + + // Check if template is system template (system templates should not be modified) + if (template.isSystemTemplate && updateData.approvalLevelsConfig) { + throw new Error('Cannot modify approval levels of system templates'); + } + + await template.update(updateData); + + logger.info(`[TemplateService] Updated template: ${templateId}`); + return template; + } catch (error) { + logger.error('[TemplateService] Error updating template:', error); + throw error; + } + } + + /** + * Delete template (soft delete by setting isActive to false) + */ + async deleteTemplate(templateId: string): Promise { + try { + const template = await WorkflowTemplate.findByPk(templateId); + if (!template) { + throw new Error('Template not found'); + } + + // Check if template is in use + const usageCount = await WorkflowRequest.count({ + where: { templateId } + }); + + if (usageCount > 0) { + throw new Error(`Cannot delete template: ${usageCount} request(s) are using this template`); + } + + // System templates cannot be deleted + if (template.isSystemTemplate) { + throw new Error('Cannot delete system templates'); + } + + // Soft delete by deactivating + await template.update({ isActive: false }); + + logger.info(`[TemplateService] Deleted (deactivated) template: ${templateId}`); + } catch (error) { + logger.error('[TemplateService] Error deleting template:', error); + throw error; + } + } + + /** + * Get active templates for workflow creation + */ + async getActiveTemplates(): Promise { + try { + return await WorkflowTemplate.findAll({ + where: { isActive: true }, + order: [['templateName', 'ASC']] + }); + } catch (error) { + logger.error('[TemplateService] Error getting active templates:', error); + throw error; + } + } + + /** + * Increment usage count when template is used + */ + async incrementUsageCount(templateId: string): Promise { + try { + await WorkflowTemplate.increment('usageCount', { + where: { templateId } + }); + } catch (error) { + logger.error('[TemplateService] Error incrementing usage count:', error); + // Don't throw - this is not critical + } + } +} + diff --git a/src/services/templateFieldResolver.service.ts b/src/services/templateFieldResolver.service.ts new file mode 100644 index 0000000..9aead91 --- /dev/null +++ b/src/services/templateFieldResolver.service.ts @@ -0,0 +1,287 @@ +import { WorkflowRequest } from '../models/WorkflowRequest'; +import { ApprovalLevel } from '../models/ApprovalLevel'; +import { User } from '../models/User'; +import { Participant } from '../models/Participant'; +import logger from '../utils/logger'; + +/** + * Interface for user reference configuration in form fields + */ +export interface UserReference { + role: 'initiator' | 'dealer' | 'approver' | 'team_lead' | 'department_lead' | 'current_approver' | 'previous_approver'; + level?: number; // For approver: which approval level + field: 'name' | 'email' | 'phone' | 'department' | 'employee_id' | 'all'; // Which user field to reference + autoPopulate: boolean; // Auto-fill from user data + editable: boolean; // Can user edit the auto-populated value +} + +/** + * Interface for form step configuration + */ +export interface FormStepConfig { + stepNumber: number; + stepName: string; + stepDescription?: string; + fields: FormFieldConfig[]; + userReferences?: UserReferenceConfig[]; +} + +export interface FormFieldConfig { + fieldId: string; + fieldType: string; + label: string; + required: boolean; + defaultValue?: any; + userReference?: UserReference; +} + +export interface UserReferenceConfig { + role: string; + captureFields: string[]; + autoPopulateFrom: 'workflow' | 'user_profile' | 'approval_level'; + allowOverride: boolean; +} + +/** + * Service to resolve user references in template forms + */ +export class TemplateFieldResolver { + /** + * Resolve user reference fields in a step + */ + async resolveUserReferences( + stepConfig: FormStepConfig, + request: WorkflowRequest, + currentUserId: string, + context?: { + currentLevel?: number; + approvers?: Map; + } + ): Promise> { + const resolvedFields: Record = {}; + + try { + for (const field of stepConfig.fields) { + if (field.userReference) { + const userData = await this.getUserDataForReference( + field.userReference, + request, + currentUserId, + context + ); + + if (field.userReference.autoPopulate && userData) { + resolvedFields[field.fieldId] = this.extractUserField( + userData, + field.userReference.field + ); + } + } + } + } catch (error) { + logger.error('[TemplateFieldResolver] Error resolving user references:', error); + } + + return resolvedFields; + } + + /** + * Get user data based on reference configuration + */ + private async getUserDataForReference( + userRef: UserReference, + request: WorkflowRequest, + currentUserId: string, + context?: any + ): Promise { + try { + switch (userRef.role) { + case 'initiator': + return await User.findByPk(request.initiatorId); + + case 'dealer': + // Get dealer from participants + const dealerParticipant = await Participant.findOne({ + where: { + requestId: request.requestId, + participantType: 'DEALER' as any, + isActive: true + }, + include: [{ model: User, as: 'user' }] + }); + return dealerParticipant?.user || null; + + case 'approver': + if (userRef.level && context?.approvers) { + const approverLevel = context.approvers.get(userRef.level); + if (approverLevel?.approverId) { + return await User.findByPk(approverLevel.approverId); + } + } + // Fallback to current approver + const currentLevel = await ApprovalLevel.findOne({ + where: { + requestId: request.requestId, + levelNumber: context?.currentLevel || request.currentLevel, + status: 'PENDING' as any + } + }); + if (currentLevel?.approverId) { + return await User.findByPk(currentLevel.approverId); + } + return null; + + case 'team_lead': + // Find team lead based on initiator's manager + const initiator = await User.findByPk(request.initiatorId); + if (initiator?.manager) { + return await User.findOne({ + where: { + email: initiator.manager, + role: 'MANAGEMENT' as any + } + }); + } + return null; + + case 'department_lead': + const initiatorUser = await User.findByPk(request.initiatorId); + if (initiatorUser?.department) { + return await User.findOne({ + where: { + department: initiatorUser.department, + role: 'MANAGEMENT' as any + }, + order: [['created_at', 'DESC']] + }); + } + return null; + + case 'current_approver': + const currentApprovalLevel = await ApprovalLevel.findOne({ + where: { + requestId: request.requestId, + status: 'PENDING' as any + }, + order: [['level_number', 'ASC']] + }); + if (currentApprovalLevel?.approverId) { + return await User.findByPk(currentApprovalLevel.approverId); + } + return null; + + case 'previous_approver': + const previousLevel = request.currentLevel - 1; + if (previousLevel > 0) { + const previousApprovalLevel = await ApprovalLevel.findOne({ + where: { + requestId: request.requestId, + levelNumber: previousLevel + } + }); + if (previousApprovalLevel?.approverId) { + return await User.findByPk(previousApprovalLevel.approverId); + } + } + return null; + + default: + return null; + } + } catch (error) { + logger.error(`[TemplateFieldResolver] Error getting user data for role ${userRef.role}:`, error); + return null; + } + } + + /** + * Extract specific field from user data + */ + private extractUserField(user: User, field: string): any { + if (!user) return null; + + switch (field) { + case 'name': + return user.displayName || `${user.firstName || ''} ${user.lastName || ''}`.trim(); + case 'email': + return user.email; + case 'phone': + return user.phone || user.mobilePhone; + case 'department': + return user.department; + case 'employee_id': + return user.employeeId; + case 'all': + return { + name: user.displayName || `${user.firstName || ''} ${user.lastName || ''}`.trim(), + email: user.email, + phone: user.phone || user.mobilePhone, + department: user.department, + employeeId: user.employeeId + }; + default: + return null; + } + } + + /** + * Resolve dynamic approver based on configuration + */ + async resolveDynamicApprover( + level: number, + config: any, // DynamicApproverConfig + request: WorkflowRequest + ): Promise { + if (!config?.enabled || !config?.approverSelection?.dynamicRules) { + return null; + } + + try { + const rule = config.approverSelection.dynamicRules.find((r: any) => r.level === level); + if (!rule) return null; + + const criteria = rule.selectionCriteria; + + switch (criteria.type) { + case 'role': + return await User.findOne({ + where: { + role: criteria.value as any + }, + order: [['created_at', 'DESC']] + }); + + case 'department': + const initiator = await User.findByPk(request.initiatorId); + const deptValue = criteria.value?.replace('${initiator.department}', initiator?.department || '') || initiator?.department; + if (deptValue) { + return await User.findOne({ + where: { + department: deptValue, + role: 'MANAGEMENT' as any + } + }); + } + return null; + + case 'manager': + const initiatorUser = await User.findByPk(request.initiatorId); + if (initiatorUser?.manager) { + return await User.findOne({ + where: { + email: initiatorUser.manager + } + }); + } + return null; + + default: + return null; + } + } catch (error) { + logger.error('[TemplateFieldResolver] Error resolving dynamic approver:', error); + return null; + } + } +} + diff --git a/src/types/auth.types.ts b/src/types/auth.types.ts index cfd550a..444bf35 100644 --- a/src/types/auth.types.ts +++ b/src/types/auth.types.ts @@ -9,6 +9,7 @@ export interface SSOUserData { designation?: string; phone?: string; reportingManagerId?: string; + manager?: string; // Optional - Manager name from Okta profile } export interface SSOConfig { @@ -20,6 +21,7 @@ export interface SSOConfig { oktaDomain: string; oktaClientId: string; oktaClientSecret: string; + oktaApiToken?: string; // Optional - SSWS token for Okta Users API } export interface AuthTokens {