Compare commits

..

No commits in common. "main" and "dealer_claim" have entirely different histories.

128 changed files with 2619 additions and 17452 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
import{a as s}from"./index-7JN9lLwu.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DbB0YGPu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B1UBYWWO.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
//# sourceMappingURL=conclusionApi-CMghC3Jo.js.map

View File

@ -0,0 +1,2 @@
import{a as t}from"./index-DtEUJDeH.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BPwaxA-i.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";async function m(n){return(await t.post(`/conclusions/${n}/generate`)).data.data}async function d(n,o){return(await t.post(`/conclusions/${n}/finalize`,{finalRemark:o})).data.data}async function f(n){return(await t.get(`/conclusions/${n}`)).data.data}export{d as finalizeConclusion,m as generateConclusion,f as getConclusion};
//# sourceMappingURL=conclusionApi-Dx0VmMvk.js.map

View File

@ -1 +1 @@
{"version":3,"file":"conclusionApi-CMghC3Jo.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"} {"version":3,"file":"conclusionApi-Dx0VmMvk.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{g as s}from"./index-DtEUJDeH.js";import"./radix-vendor-DA0cB_hD.js";import"./charts-vendor-Cji9-Yri.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-BPwaxA-i.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-CRr9x_Jp.js";function R(o){const{requestId:e,status:t,request:a,navigate:r}=o;if((t==null?void 0:t.toLowerCase())==="draft"||t==="DRAFT"){r(`/edit-request/${e}`);return}const i=s(e);r(i)}export{R as navigateToRequest};
//# sourceMappingURL=requestNavigation-DjrXcYns.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"requestNavigation-DjrXcYns.js","sources":["../../src/utils/requestNavigation.ts"],"sourcesContent":["/**\r\n * Global Request Navigation Utility\r\n * \r\n * Centralized navigation logic for request-related routes.\r\n * This utility decides where to navigate when clicking on request cards\r\n * from anywhere in the application.\r\n * \r\n * Features:\r\n * - Single point of navigation logic\r\n * - Handles draft vs active requests\r\n * - Supports different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Type-safe navigation\r\n */\r\n\r\nimport { NavigateFunction } from 'react-router-dom';\r\nimport { getRequestDetailRoute, RequestFlowType } from './requestTypeUtils';\r\n\r\nexport interface RequestNavigationOptions {\r\n requestId: string;\r\n requestTitle?: string;\r\n status?: string;\r\n request?: any; // Full request object if available\r\n navigate: NavigateFunction;\r\n}\r\n\r\n/**\r\n * Navigate to the appropriate request detail page based on request type\r\n * \r\n * This is the single point of navigation for all request cards.\r\n * It handles:\r\n * - Draft requests (navigate to edit)\r\n * - Different flow types (CUSTOM, DEALER_CLAIM)\r\n * - Status-based routing\r\n */\r\nexport function navigateToRequest(options: RequestNavigationOptions): void {\r\n const { requestId, status, request, navigate } = options;\r\n\r\n // Check if request is a draft - if so, route to edit form instead of detail view\r\n const isDraft = status?.toLowerCase() === 'draft' || status === 'DRAFT';\r\n if (isDraft) {\r\n navigate(`/edit-request/${requestId}`);\r\n return;\r\n }\r\n\r\n // Determine the appropriate route based on request type\r\n const route = getRequestDetailRoute(requestId, request);\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Navigate to create a new request based on flow type\r\n */\r\nexport function navigateToCreateRequest(\r\n navigate: NavigateFunction,\r\n flowType: RequestFlowType = 'CUSTOM'\r\n): void {\r\n const route = flowType === 'DEALER_CLAIM' \r\n ? '/claim-management' \r\n : '/new-request';\r\n navigate(route);\r\n}\r\n\r\n/**\r\n * Create a navigation handler function for request cards\r\n * This can be used directly in onClick handlers\r\n */\r\nexport function createRequestNavigationHandler(\r\n navigate: NavigateFunction\r\n) {\r\n return (requestId: string, requestTitle?: string, status?: string, request?: any) => {\r\n navigateToRequest({\r\n requestId,\r\n requestTitle,\r\n status,\r\n request,\r\n navigate,\r\n });\r\n };\r\n}\r\n"],"names":["navigateToRequest","options","requestId","status","request","navigate","route","getRequestDetailRoute"],"mappings":"6RAkCO,SAASA,EAAkBC,EAAyC,CACzE,KAAM,CAAE,UAAAC,EAAW,OAAAC,EAAQ,QAAAC,EAAS,SAAAC,GAAaJ,EAIjD,IADgBE,GAAA,YAAAA,EAAQ,iBAAkB,SAAWA,IAAW,QACnD,CACXE,EAAS,iBAAiBH,CAAS,EAAE,EACrC,MACF,CAGA,MAAMI,EAAQC,EAAsBL,CAAkB,EACtDG,EAASC,CAAK,CAChB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,15 +52,15 @@
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-7JN9lLwu.js"></script> <script type="module" crossorigin src="/assets/index-DtEUJDeH.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js"> <link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DIkYAdWy.js"> <link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DbB0YGPu.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-BPwaxA-i.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js"> <link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js"> <link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B1UBYWWO.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-CRr9x_Jp.js">
<link rel="stylesheet" crossorigin href="/assets/index-B-mLDzJe.css"> <link rel="stylesheet" crossorigin href="/assets/index-P-Le9vHs.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -15,16 +15,15 @@
## Overview ## Overview
The AI Conclusion Remark Generation feature automatically generates professional, context-aware conclusion remarks for workflow requests that have been approved or rejected. This feature uses **Google Cloud Vertex AI Gemini** to analyze the entire request lifecycle and create a comprehensive summary suitable for permanent archiving. The AI Conclusion Remark Generation feature automatically generates professional, context-aware conclusion remarks for workflow requests that have been approved or rejected. This feature uses AI providers (Claude, OpenAI, or Gemini) to analyze the entire request lifecycle and create a comprehensive summary suitable for permanent archiving.
### Key Features ### Key Features
- **Vertex AI Integration**: Uses Google Cloud Vertex AI Gemini with service account authentication - **Multi-Provider Support**: Supports Claude (Anthropic), OpenAI (GPT-4), and Google Gemini
- **Context-Aware**: Analyzes approval flow, work notes, documents, and activities - **Context-Aware**: Analyzes approval flow, work notes, documents, and activities
- **Configurable**: Admin-configurable max length, model selection, and enable/disable - **Configurable**: Admin-configurable max length, provider selection, and enable/disable
- **Automatic Generation**: Can be triggered automatically when a request is approved/rejected - **Automatic Generation**: Can be triggered automatically when a request is approved/rejected
- **Manual Generation**: Users can regenerate conclusions on demand - **Manual Generation**: Users can regenerate conclusions on demand
- **Editable**: Generated remarks can be edited before finalization - **Editable**: Generated remarks can be edited before finalization
- **Enterprise Security**: Uses same service account credentials as Google Cloud Storage
### Use Cases ### Use Cases
1. **Automatic Generation**: When the final approver approves/rejects a request, an AI conclusion is generated in the background 1. **Automatic Generation**: When the final approver approves/rejects a request, an AI conclusion is generated in the background
@ -75,10 +74,10 @@ The AI Conclusion Remark Generation feature automatically generates professional
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────────┐ │
│ │ Vertex AI Gemini (Google Cloud) │ │ │ │ AI Providers (Claude/OpenAI/Gemini) │ │
│ │ - VertexAI Client │ │ │ │ - ClaudeProvider │ │
│ │ - Service Account Authentication │ │ │ │ - OpenAIProvider │ │
│ │ - Gemini Models (gemini-2.5-flash, etc.) │ │ │ │ - GeminiProvider │ │
│ └──────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
@ -115,18 +114,22 @@ The AI Conclusion Remark Generation feature automatically generates professional
### Environment Variables ### Environment Variables
```bash ```bash
# Google Cloud Configuration (required - same as GCS) # AI Provider Selection (claude, openai, gemini)
GCP_PROJECT_ID=re-platform-workflow-dealer AI_PROVIDER=claude
GCP_KEY_FILE=./credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
# Vertex AI Configuration (optional - defaults provided) # Claude Configuration
VERTEX_AI_MODEL=gemini-2.5-flash CLAUDE_API_KEY=your_claude_api_key
VERTEX_AI_LOCATION=asia-south1 CLAUDE_MODEL=claude-sonnet-4-20250514
AI_ENABLED=true
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key
OPENAI_MODEL=gpt-4o
# Gemini Configuration
GEMINI_API_KEY=your_gemini_api_key
GEMINI_MODEL=gemini-2.0-flash-lite
``` ```
**Note**: The service account key file is the same one used for Google Cloud Storage, ensuring consistent authentication across services.
### Admin Configuration (Database) ### Admin Configuration (Database)
The system reads configuration from the `system_config` table. Key settings: The system reads configuration from the `system_config` table. Key settings:
@ -135,29 +138,21 @@ The system reads configuration from the `system_config` table. Key settings:
|------------|---------|-------------| |------------|---------|-------------|
| `AI_ENABLED` | `true` | Enable/disable all AI features | | `AI_ENABLED` | `true` | Enable/disable all AI features |
| `AI_REMARK_GENERATION_ENABLED` | `true` | Enable/disable conclusion generation | | `AI_REMARK_GENERATION_ENABLED` | `true` | Enable/disable conclusion generation |
| `AI_PROVIDER` | `claude` | Preferred AI provider (claude, openai, gemini) |
| `AI_MAX_REMARK_LENGTH` | `2000` | Maximum characters for generated remarks | | `AI_MAX_REMARK_LENGTH` | `2000` | Maximum characters for generated remarks |
| `VERTEX_AI_MODEL` | `gemini-2.5-flash` | Vertex AI Gemini model name | | `CLAUDE_API_KEY` | - | Claude API key (if using Claude) |
| `CLAUDE_MODEL` | `claude-sonnet-4-20250514` | Claude model name |
| `OPENAI_API_KEY` | - | OpenAI API key (if using OpenAI) |
| `OPENAI_MODEL` | `gpt-4o` | OpenAI model name |
| `GEMINI_API_KEY` | - | Gemini API key (if using Gemini) |
| `GEMINI_MODEL` | `gemini-2.0-flash-lite` | Gemini model name |
### Available Models ### Provider Priority
| Model Name | Description | Use Case | 1. **Preferred Provider**: Set via `AI_PROVIDER` config
|------------|-------------|----------| 2. **Fallback Chain**: If preferred fails, tries:
| `gemini-2.5-flash` | Latest fast model (default) | General purpose, quick responses | - Claude → OpenAI → Gemini
| `gemini-1.5-flash` | Previous fast model | General purpose | 3. **Environment Fallback**: If database config fails, uses environment variables
| `gemini-1.5-pro` | Advanced model | Complex tasks, better quality |
| `gemini-1.5-pro-latest` | Latest Pro version | Best quality, complex reasoning |
### Supported Regions
| Region Code | Location | Availability |
|-------------|----------|--------------|
| `us-central1` | Iowa, USA | ✅ Default |
| `us-east1` | South Carolina, USA | ✅ |
| `us-west1` | Oregon, USA | ✅ |
| `europe-west1` | Belgium | ✅ |
| `asia-south1` | Mumbai, India | ✅ (Current default) |
**Note**: Model and region are configured via environment variables, not database config.
--- ---
@ -191,7 +186,7 @@ Authorization: Bearer <token>
], ],
"confidence": 0.85, "confidence": 0.85,
"generatedAt": "2025-01-15T10:30:00Z", "generatedAt": "2025-01-15T10:30:00Z",
"provider": "Vertex AI (Gemini)" "provider": "Claude (Anthropic)"
} }
} }
``` ```
@ -259,7 +254,7 @@ Content-Type: application/json
"finalRemark": "Finalized text...", "finalRemark": "Finalized text...",
"isEdited": true, "isEdited": true,
"editCount": 2, "editCount": 2,
"aiModelUsed": "Vertex AI (Gemini)", "aiModelUsed": "Claude (Anthropic)",
"aiConfidenceScore": 0.85, "aiConfidenceScore": 0.85,
"keyDiscussionPoints": ["Point 1", "Point 2"], "keyDiscussionPoints": ["Point 1", "Point 2"],
"generatedAt": "2025-01-15T10:30:00Z", "generatedAt": "2025-01-15T10:30:00Z",
@ -329,9 +324,9 @@ interface ConclusionContext {
- Sets target word count based on `AI_MAX_REMARK_LENGTH` - Sets target word count based on `AI_MAX_REMARK_LENGTH`
3. **AI Generation**: 3. **AI Generation**:
- Sends prompt to Vertex AI Gemini - Sends prompt to selected AI provider
- Receives generated text (up to 4096 tokens) - Receives generated text
- Preserves full AI response (no truncation) - Validates length (trims if exceeds max)
- Extracts key points - Extracts key points
- Calculates confidence score - Calculates confidence score
@ -412,24 +407,13 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
4. **Tone Guidelines**: Emphasizes natural, professional, archival-quality writing 4. **Tone Guidelines**: Emphasizes natural, professional, archival-quality writing
5. **Context Awareness**: Includes all relevant data (approvals, notes, documents, activities) 5. **Context Awareness**: Includes all relevant data (approvals, notes, documents, activities)
### Vertex AI Settings ### Provider-Specific Settings
| Setting | Value | Description | | Provider | Model | Max Tokens | Temperature | Notes |
|---------|-------|-------------| |----------|-------|------------|-------------|-------|
| Model | `gemini-2.5-flash` (default) | Fast, efficient model for conclusion generation | | Claude | claude-sonnet-4-20250514 | 2048 | 0.3 | Best for longer, detailed conclusions |
| Max Output Tokens | `4096` | Maximum tokens in response (technical limit) | | OpenAI | gpt-4o | 1024 | 0.3 | Balanced performance |
| Character Limit | `2000` (configurable) | Actual limit enforced via prompt (`AI_MAX_REMARK_LENGTH`) | | Gemini | gemini-2.0-flash-lite | - | 0.3 | Fast and cost-effective |
| Temperature | `0.3` | Lower temperature for more focused, consistent output |
| Location | `asia-south1` (default) | Google Cloud region for API calls |
| Authentication | Service Account | Uses same credentials as Google Cloud Storage |
**Note on Token vs Character Limits:**
- **4096 tokens** is the technical maximum Vertex AI can generate
- **2000 characters** (default) is the actual limit enforced by the prompt
- Token-to-character conversion: ~1 token ≈ 3-4 characters
- With HTML tags: 4096 tokens ≈ 12,000-16,000 characters (including tags)
- The AI is instructed to stay within the character limit, not the token limit
- The token limit provides headroom but the character limit is what matters for storage
--- ---
@ -439,21 +423,15 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
1. **No AI Provider Available** 1. **No AI Provider Available**
``` ```
Error: AI features are currently unavailable. Please verify Vertex AI configuration and service account credentials. Error: AI features are currently unavailable. Please configure an AI provider...
``` ```
**Solution**: **Solution**: Configure API keys in admin panel or environment variables
- Verify service account key file exists at path specified in `GCP_KEY_FILE`
- Ensure Vertex AI API is enabled in Google Cloud Console
- Check service account has `Vertex AI User` role (`roles/aiplatform.user`)
2. **Vertex AI API Error** 2. **Provider API Error**
``` ```
Error: AI generation failed (Vertex AI): Model was not found or your project does not have access Error: AI generation failed (Claude): API rate limit exceeded
``` ```
**Solution**: **Solution**: Check API key validity, rate limits, and provider status
- Verify model name is correct (e.g., `gemini-2.5-flash`)
- Ensure model is available in selected region
- Check Vertex AI API is enabled in Google Cloud Console
3. **Request Not Found** 3. **Request Not Found**
``` ```
@ -475,10 +453,10 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
### Error Recovery ### Error Recovery
- **Automatic Fallback**: If preferred provider fails, system tries fallback providers
- **Graceful Degradation**: If AI generation fails, user can write conclusion manually - **Graceful Degradation**: If AI generation fails, user can write conclusion manually
- **Retry Logic**: Manual regeneration is always available - **Retry Logic**: Manual regeneration is always available
- **Logging**: All errors are logged with context for debugging - **Logging**: All errors are logged with context for debugging
- **Token Limit Handling**: If response hits token limit, full response is preserved (no truncation)
--- ---
@ -494,17 +472,14 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
### For Administrators ### For Administrators
1. **Service Account Setup**: 1. **API Key Management**: Store API keys securely in database or environment variables
- Ensure service account key file exists and is accessible 2. **Provider Selection**: Choose provider based on:
- Verify service account has `Vertex AI User` role - **Claude**: Best quality, higher cost
- Use same credentials as Google Cloud Storage for consistency - **OpenAI**: Balanced quality/cost
2. **Model Selection**: Choose model based on needs: - **Gemini**: Fast, cost-effective
- **gemini-2.5-flash**: Fast, cost-effective (default, recommended)
- **gemini-1.5-pro**: Better quality for complex requests
3. **Length Configuration**: Set `AI_MAX_REMARK_LENGTH` based on your archival needs 3. **Length Configuration**: Set `AI_MAX_REMARK_LENGTH` based on your archival needs
4. **Monitoring**: Monitor AI usage and costs through Google Cloud Console 4. **Monitoring**: Monitor AI usage and costs through provider dashboards
5. **Testing**: Test with sample requests before enabling in production 5. **Testing**: Test with sample requests before enabling in production
6. **Region Selection**: Choose region closest to your deployment for lower latency
### For Users ### For Users
@ -524,10 +499,8 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
**Diagnosis**: **Diagnosis**:
1. Check `AI_ENABLED` config value 1. Check `AI_ENABLED` config value
2. Check `AI_REMARK_GENERATION_ENABLED` config value 2. Check `AI_REMARK_GENERATION_ENABLED` config value
3. Verify service account key file exists and is accessible 3. Verify API keys are configured
4. Check Vertex AI API is enabled in Google Cloud Console 4. Check provider initialization logs
5. Verify service account has `Vertex AI User` role
6. Check provider initialization logs
**Solution**: **Solution**:
```bash ```bash
@ -536,14 +509,6 @@ tail -f logs/app.log | grep "AI Service"
# Verify config # Verify config
SELECT * FROM system_config WHERE config_key LIKE 'AI_%'; SELECT * FROM system_config WHERE config_key LIKE 'AI_%';
# Verify service account key file
ls -la credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
# Check environment variables
echo $GCP_PROJECT_ID
echo $GCP_KEY_FILE
echo $VERTEX_AI_MODEL
``` ```
### Issue: Generated Text Too Long/Short ### Issue: Generated Text Too Long/Short
@ -553,8 +518,7 @@ echo $VERTEX_AI_MODEL
**Solution**: **Solution**:
1. Adjust `AI_MAX_REMARK_LENGTH` in admin config 1. Adjust `AI_MAX_REMARK_LENGTH` in admin config
2. Check prompt target word count calculation 2. Check prompt target word count calculation
3. Note: Vertex AI max output tokens is 4096 (system handles this automatically) 3. Verify provider max_tokens setting
4. AI is instructed to stay within character limit, but full response is preserved
### Issue: Poor Quality Conclusions ### Issue: Poor Quality Conclusions
@ -563,50 +527,37 @@ echo $VERTEX_AI_MODEL
**Solution**: **Solution**:
1. Verify context data is complete (approvals, notes, documents) 1. Verify context data is complete (approvals, notes, documents)
2. Check prompt includes all relevant information 2. Check prompt includes all relevant information
3. Try different model (e.g., `gemini-1.5-pro` for better quality) 3. Try different provider (Claude generally produces better quality)
4. Temperature is set to 0.3 for focused output (can be adjusted in code if needed) 4. Adjust temperature if needed (lower = more focused)
### Issue: Slow Generation ### Issue: Slow Generation
**Symptoms**: AI generation takes too long **Symptoms**: AI generation takes too long
**Solution**: **Solution**:
1. Check Vertex AI API status in Google Cloud Console 1. Check provider API status
2. Verify network connectivity 2. Verify network connectivity
3. Consider using `gemini-2.5-flash` model (fastest option) 3. Consider using faster provider (Gemini)
4. Check for rate limiting in Google Cloud Console 4. Check for rate limiting
5. Verify region selection (closer region = lower latency)
### Issue: Vertex AI Not Initializing ### Issue: Provider Not Initializing
**Symptoms**: Provider shows as "None" or initialization fails in logs **Symptoms**: Provider shows as "None" in logs
**Diagnosis**: **Diagnosis**:
1. Check service account key file exists and is valid 1. Check API key is valid
2. Verify `@google-cloud/vertexai` package is installed 2. Verify SDK package is installed
3. Check environment variables (`GCP_PROJECT_ID`, `GCP_KEY_FILE`) 3. Check environment variables
4. Verify Vertex AI API is enabled in Google Cloud Console
5. Check service account permissions
**Solution**: **Solution**:
```bash ```bash
# Install missing SDK # Install missing SDK
npm install @google-cloud/vertexai npm install @anthropic-ai/sdk # For Claude
npm install openai # For OpenAI
npm install @google/generative-ai # For Gemini
# Verify service account key file # Verify API key
ls -la credentials/re-platform-workflow-dealer-3d5738fcc1f9.json echo $CLAUDE_API_KEY # Should show key
# Verify environment variables
echo $GCP_PROJECT_ID
echo $GCP_KEY_FILE
echo $VERTEX_AI_MODEL
echo $VERTEX_AI_LOCATION
# Check Google Cloud Console
# 1. Go to APIs & Services > Library
# 2. Search for "Vertex AI API"
# 3. Ensure it's enabled
# 4. Verify service account has "Vertex AI User" role
``` ```
--- ---
@ -693,13 +644,12 @@ reference.
## Version History ## Version History
- **v2.0.0**: Vertex AI Migration - **v1.0.0** (2025-01-15): Initial implementation
- Migrated to Google Cloud Vertex AI Gemini - Multi-provider support (Claude, OpenAI, Gemini)
- Service account authentication (same as GCS) - Automatic and manual generation
- Removed multi-provider support - TAT risk integration
- Increased max output tokens to 4096 - Key points extraction
- Full response preservation (no truncation) - Confidence scoring
- HTML format support for rich text editor
--- ---
@ -709,18 +659,13 @@ For issues or questions:
1. Check logs: `logs/app.log` 1. Check logs: `logs/app.log`
2. Review admin configuration panel 2. Review admin configuration panel
3. Contact development team 3. Contact development team
4. Refer to Vertex AI documentation: 4. Refer to provider documentation:
- [Vertex AI Documentation](https://cloud.google.com/vertex-ai/docs) - [Claude API Docs](https://docs.anthropic.com)
- [Gemini Models](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini) - [OpenAI API Docs](https://platform.openai.com/docs)
- [Vertex AI Setup Guide](../VERTEX_AI_INTEGRATION.md) - [Gemini API Docs](https://ai.google.dev/docs)
--- ---
**Last Updated**: January 2025
**Maintained By**: Royal Enfield Development Team **Maintained By**: Royal Enfield Development Team
---
## Related Documentation
- [Vertex AI Integration Guide](./VERTEX_AI_INTEGRATION.md) - Detailed setup and migration information

View File

@ -1,224 +0,0 @@
-- ============================================================
-- DEALERS CSV IMPORT - WORKING SOLUTION
-- ============================================================
-- This script provides a working solution for importing dealers
-- from CSV with auto-generated columns (dealer_id, created_at, updated_at, is_active)
-- ============================================================
-- METHOD 1: If your CSV does NOT have dealer_id, created_at, updated_at, is_active
-- ============================================================
-- Use this COPY command if your CSV has exactly 44 columns (without the auto-generated ones)
\copy public.dealers (sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,"date",single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode) FROM 'C:/Users/BACKPACKERS/Downloads/Dealer_Master.csv' CSV HEADER ENCODING 'WIN1252';
-- ============================================================
-- METHOD 2: If your CSV HAS dealer_id, created_at, updated_at, is_active columns
-- ============================================================
-- Use this approach if your CSV has 48 columns (including the auto-generated ones)
-- This creates a temporary table, imports, then inserts with defaults
-- Step 1: Create temporary table matching your CSV structure
-- This accepts ALL columns from CSV (whether 44 or 48 columns)
CREATE TEMP TABLE dealers_temp (
dealer_id TEXT,
sales_code TEXT,
service_code TEXT,
gear_code TEXT,
gma_code TEXT,
region TEXT,
dealership TEXT,
state TEXT,
district TEXT,
city TEXT,
location TEXT,
city_category_pst TEXT,
layout_format TEXT,
tier_city_category TEXT,
on_boarding_charges TEXT,
date TEXT,
single_format_month_year TEXT,
domain_id TEXT,
replacement TEXT,
termination_resignation_status TEXT,
date_of_termination_resignation TEXT,
last_date_of_operations TEXT,
old_codes TEXT,
branch_details TEXT,
dealer_principal_name TEXT,
dealer_principal_email_id TEXT,
dp_contact_number TEXT,
dp_contacts TEXT,
showroom_address TEXT,
showroom_pincode TEXT,
workshop_address TEXT,
workshop_pincode TEXT,
location_district TEXT,
state_workshop TEXT,
no_of_studios TEXT,
website_update TEXT,
gst TEXT,
pan TEXT,
firm_type TEXT,
prop_managing_partners_directors TEXT,
total_prop_partners_directors TEXT,
docs_folder_link TEXT,
workshop_gma_codes TEXT,
existing_new TEXT,
dlrcode TEXT,
created_at TEXT,
updated_at TEXT,
is_active TEXT
);
-- Step 2: Import CSV into temporary table
-- This will work whether your CSV has 44 or 48 columns
\copy dealers_temp FROM 'C:/Users/COMP/Downloads/DEALERS_CLEAN.csv' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8');
-- Optional: Check what was imported
-- SELECT COUNT(*) FROM dealers_temp;
-- Step 3: Insert into actual dealers table
-- IMPORTANT: We IGNORE dealer_id, created_at, updated_at, is_active from CSV
-- These will use database DEFAULT values (auto-generated UUID, current timestamp, true)
INSERT INTO public.dealers (
sales_code,
service_code,
gear_code,
gma_code,
region,
dealership,
state,
district,
city,
location,
city_category_pst,
layout_format,
tier_city_category,
on_boarding_charges,
date,
single_format_month_year,
domain_id,
replacement,
termination_resignation_status,
date_of_termination_resignation,
last_date_of_operations,
old_codes,
branch_details,
dealer_principal_name,
dealer_principal_email_id,
dp_contact_number,
dp_contacts,
showroom_address,
showroom_pincode,
workshop_address,
workshop_pincode,
location_district,
state_workshop,
no_of_studios,
website_update,
gst,
pan,
firm_type,
prop_managing_partners_directors,
total_prop_partners_directors,
docs_folder_link,
workshop_gma_codes,
existing_new,
dlrcode
)
SELECT
NULLIF(sales_code, ''),
NULLIF(service_code, ''),
NULLIF(gear_code, ''),
NULLIF(gma_code, ''),
NULLIF(region, ''),
NULLIF(dealership, ''),
NULLIF(state, ''),
NULLIF(district, ''),
NULLIF(city, ''),
NULLIF(location, ''),
NULLIF(city_category_pst, ''),
NULLIF(layout_format, ''),
NULLIF(tier_city_category, ''),
NULLIF(on_boarding_charges, ''),
NULLIF(date, ''),
NULLIF(single_format_month_year, ''),
NULLIF(domain_id, ''),
NULLIF(replacement, ''),
NULLIF(termination_resignation_status, ''),
NULLIF(date_of_termination_resignation, ''),
NULLIF(last_date_of_operations, ''),
NULLIF(old_codes, ''),
NULLIF(branch_details, ''),
NULLIF(dealer_principal_name, ''),
NULLIF(dealer_principal_email_id, ''),
NULLIF(dp_contact_number, ''),
NULLIF(dp_contacts, ''),
NULLIF(showroom_address, ''),
NULLIF(showroom_pincode, ''),
NULLIF(workshop_address, ''),
NULLIF(workshop_pincode, ''),
NULLIF(location_district, ''),
NULLIF(state_workshop, ''),
CASE WHEN no_of_studios = '' THEN 0 ELSE no_of_studios::INTEGER END,
NULLIF(website_update, ''),
NULLIF(gst, ''),
NULLIF(pan, ''),
NULLIF(firm_type, ''),
NULLIF(prop_managing_partners_directors, ''),
NULLIF(total_prop_partners_directors, ''),
NULLIF(docs_folder_link, ''),
NULLIF(workshop_gma_codes, ''),
NULLIF(existing_new, ''),
NULLIF(dlrcode, '')
FROM dealers_temp;
-- Step 4: Clean up temporary table
DROP TABLE dealers_temp;
-- ============================================================
-- METHOD 3: Using COPY with DEFAULT (PostgreSQL 12+)
-- ============================================================
-- Alternative approach using a function to set defaults
-- Create a function to handle the import with defaults
CREATE OR REPLACE FUNCTION import_dealers_from_csv()
RETURNS void AS $$
BEGIN
-- This will be called from a COPY command that uses a function
-- See METHOD 1 for the actual COPY command
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- VERIFICATION QUERIES
-- ============================================================
-- Check import results
SELECT
COUNT(*) as total_dealers,
COUNT(dealer_id) as has_dealer_id,
COUNT(created_at) as has_created_at,
COUNT(updated_at) as has_updated_at,
COUNT(*) FILTER (WHERE is_active = true) as active_count
FROM dealers;
-- View sample records with auto-generated values
SELECT
dealer_id,
dlrcode,
dealership,
created_at,
updated_at,
is_active
FROM dealers
LIMIT 5;
-- Check for any issues
SELECT
COUNT(*) FILTER (WHERE dealer_id IS NULL) as missing_dealer_id,
COUNT(*) FILTER (WHERE created_at IS NULL) as missing_created_at,
COUNT(*) FILTER (WHERE updated_at IS NULL) as missing_updated_at
FROM dealers;

View File

@ -1,515 +0,0 @@
# Dealers CSV Import Guide
This guide explains how to format and import dealer data from a CSV file into the PostgreSQL `dealers` table.
## ⚠️ Important: Auto-Generated Columns
**DO NOT include these columns in your CSV file** - they are automatically generated by the database:
- ❌ `dealer_id` - Auto-generated UUID (e.g., `550e8400-e29b-41d4-a716-446655440000`)
- ❌ `created_at` - Auto-generated timestamp (current time on import)
- ❌ `updated_at` - Auto-generated timestamp (current time on import)
- ❌ `is_active` - Defaults to `true`
Your CSV should have **exactly 44 columns** (the data columns listed below).
## Table of Contents
- [CSV File Format Requirements](#csv-file-format-requirements)
- [Column Mapping](#column-mapping)
- [Preparing Your CSV File](#preparing-your-csv-file)
- [Import Methods](#import-methods)
- [Troubleshooting](#troubleshooting)
---
## CSV File Format Requirements
### File Requirements
- **Format**: CSV (Comma-Separated Values)
- **Encoding**: UTF-8
- **Header Row**: Required (first row must contain column names)
- **Delimiter**: Comma (`,`)
- **Text Qualifier**: Double quotes (`"`) for fields containing commas or special characters
### Required Columns (in exact order)
**Important Notes:**
- **DO NOT include** `dealer_id`, `created_at`, `updated_at`, or `is_active` in your CSV file
- These columns will be automatically generated by the database:
- `dealer_id`: Auto-generated UUID
- `created_at`: Auto-generated timestamp (current time)
- `updated_at`: Auto-generated timestamp (current time)
- `is_active`: Defaults to `true`
Your CSV file must have these **44 columns** in the following order:
1. `sales_code`
2. `service_code`
3. `gear_code`
4. `gma_code`
5. `region`
6. `dealership`
7. `state`
8. `district`
9. `city`
10. `location`
11. `city_category_pst`
12. `layout_format`
13. `tier_city_category`
14. `on_boarding_charges`
15. `date`
16. `single_format_month_year`
17. `domain_id`
18. `replacement`
19. `termination_resignation_status`
20. `date_of_termination_resignation`
21. `last_date_of_operations`
22. `old_codes`
23. `branch_details`
24. `dealer_principal_name`
25. `dealer_principal_email_id`
26. `dp_contact_number`
27. `dp_contacts`
28. `showroom_address`
29. `showroom_pincode`
30. `workshop_address`
31. `workshop_pincode`
32. `location_district`
33. `state_workshop`
34. `no_of_studios`
35. `website_update`
36. `gst`
37. `pan`
38. `firm_type`
39. `prop_managing_partners_directors`
40. `total_prop_partners_directors`
41. `docs_folder_link`
42. `workshop_gma_codes`
43. `existing_new`
44. `dlrcode`
---
## Column Mapping
### Column Details
| Column Name | Type | Required | Notes |
|------------|------|----------|-------|
| `sales_code` | String(50) | No | Sales code identifier |
| `service_code` | String(50) | No | Service code identifier |
| `gear_code` | String(50) | No | Gear code identifier |
| `gma_code` | String(50) | No | GMA code identifier |
| `region` | String(50) | No | Geographic region |
| `dealership` | String(255) | No | Dealership business name |
| `state` | String(100) | No | State name |
| `district` | String(100) | No | District name |
| `city` | String(100) | No | City name |
| `location` | String(255) | No | Location details |
| `city_category_pst` | String(50) | No | City category (PST) |
| `layout_format` | String(50) | No | Layout format |
| `tier_city_category` | String(100) | No | TIER City Category |
| `on_boarding_charges` | Decimal | No | Numeric value (e.g., 1000.50) |
| `date` | Date | No | Format: YYYY-MM-DD (e.g., 2014-09-30) |
| `single_format_month_year` | String(50) | No | Format: Sep-2014 |
| `domain_id` | String(255) | No | Email domain (e.g., dealer@royalenfield.com) |
| `replacement` | String(50) | No | Replacement status |
| `termination_resignation_status` | String(255) | No | Termination/Resignation status |
| `date_of_termination_resignation` | Date | No | Format: YYYY-MM-DD |
| `last_date_of_operations` | Date | No | Format: YYYY-MM-DD |
| `old_codes` | String(255) | No | Old code references |
| `branch_details` | Text | No | Branch information |
| `dealer_principal_name` | String(255) | No | Principal's full name |
| `dealer_principal_email_id` | String(255) | No | Principal's email |
| `dp_contact_number` | String(20) | No | Contact phone number |
| `dp_contacts` | String(20) | No | Additional contacts |
| `showroom_address` | Text | No | Full showroom address |
| `showroom_pincode` | String(10) | No | Showroom postal code |
| `workshop_address` | Text | No | Full workshop address |
| `workshop_pincode` | String(10) | No | Workshop postal code |
| `location_district` | String(100) | No | Location/District |
| `state_workshop` | String(100) | No | State for workshop |
| `no_of_studios` | Integer | No | Number of studios (default: 0) |
| `website_update` | String(10) | No | Yes/No value |
| `gst` | String(50) | No | GST number |
| `pan` | String(50) | No | PAN number |
| `firm_type` | String(100) | No | Type of firm (e.g., Proprietorship) |
| `prop_managing_partners_directors` | String(255) | No | Proprietor/Partners/Directors names |
| `total_prop_partners_directors` | String(255) | No | Total count or names |
| `docs_folder_link` | Text | No | Google Drive or document folder URL |
| `workshop_gma_codes` | String(255) | No | Workshop GMA codes |
| `existing_new` | String(50) | No | Existing/New status |
| `dlrcode` | String(50) | No | Dealer code |
---
## Preparing Your CSV File
### Step 1: Create/Edit Your CSV File
1. **Open your CSV file** in Excel, Google Sheets, or a text editor
2. **Remove auto-generated columns** (if present):
- ❌ **DO NOT include**: `dealer_id`, `created_at`, `updated_at`, `is_active`
- ✅ These will be automatically generated by the database
3. **Ensure the header row** matches the column names exactly (see [Column Mapping](#column-mapping))
4. **Verify column order** - columns must be in the exact order listed above (44 columns total)
5. **Check data formats**:
- Dates: Use `YYYY-MM-DD` format (e.g., `2014-09-30`)
- Numbers: Use decimal format for `on_boarding_charges` (e.g., `1000.50`)
- Empty values: Leave cells empty (don't use "NULL" or "N/A" as text)
### Step 2: Handle Special Characters
- **Commas in text**: Wrap the entire field in double quotes
- Example: `"No.335, HVP RR Nagar Sector B"`
- **Quotes in text**: Use double quotes to escape: `""quoted text""`
- **Newlines in text**: Wrap field in double quotes
### Step 3: Date Formatting
Ensure dates are in `YYYY-MM-DD` format:
- ✅ Correct: `2014-09-30`
- ❌ Wrong: `30-Sep-14`, `09/30/2014`, `30-09-2014`
### Step 4: Save the File
1. **Save as CSV** (UTF-8 encoding)
2. **File location**: Save to an accessible path (e.g., `C:/Users/COMP/Downloads/DEALERS_CLEAN.csv`)
3. **File name**: Use a descriptive name (e.g., `DEALERS_CLEAN.csv`)
### Sample CSV Format
**Important:** Your CSV should **NOT** include `dealer_id`, `created_at`, `updated_at`, or `is_active` columns. These are auto-generated.
```csv
sales_code,service_code,gear_code,gma_code,region,dealership,state,district,city,location,city_category_pst,layout_format,tier_city_category,on_boarding_charges,date,single_format_month_year,domain_id,replacement,termination_resignation_status,date_of_termination_resignation,last_date_of_operations,old_codes,branch_details,dealer_principal_name,dealer_principal_email_id,dp_contact_number,dp_contacts,showroom_address,showroom_pincode,workshop_address,workshop_pincode,location_district,state_workshop,no_of_studios,website_update,gst,pan,firm_type,prop_managing_partners_directors,total_prop_partners_directors,docs_folder_link,workshop_gma_codes,existing_new,dlrcode
5124,5125,5573,9430,S3,Accelerate Motors,Karnataka,Bengaluru,Bengaluru,RAJA RAJESHWARI NAGAR,A+,A+,Tier 1 City,,2014-09-30,Sep-2014,acceleratemotors.rrnagar@dealer.royalenfield.com,,,,,,,N. Shyam Charmanna,shyamcharmanna@yahoo.co.in,7022049621,7022049621,"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship, Bangalore - 560098, Dist Bangalore, Karnataka",560098,"Works Shop No.460, 80ft Road, 2nd Phase R R Nagar, Bangalore - 560098, Dist Bangalore, Karnataka",560098,Bangalore,Karnataka,0,Yes,29ARCPS1311D1Z6,ARCPS1311D,Proprietorship,CHARMANNA SHYAM NELLAMAKADA,CHARMANNA SHYAM NELLAMAKADA,https://drive.google.com/drive/folders/1sGtg3s1h9aBXX9fhxJufYuBWar8gVvnb,,,3386
```
**What gets auto-generated:**
- `dealer_id`: `550e8400-e29b-41d4-a716-446655440000` (example UUID)
- `created_at`: `2025-01-20 10:30:45.123` (current timestamp)
- `updated_at`: `2025-01-20 10:30:45.123` (current timestamp)
- `is_active`: `true`
---
## Import Methods
### Method 1: PostgreSQL COPY Command (Recommended - If CSV has 44 columns)
**Use this if your CSV does NOT include `dealer_id`, `created_at`, `updated_at`, `is_active` columns.**
**Prerequisites:**
- PostgreSQL client (psql) installed
- Access to PostgreSQL server
- CSV file path accessible from PostgreSQL server
**Steps:**
1. **Connect to PostgreSQL:**
```bash
psql -U your_username -d royal_enfield_workflow -h localhost
```
2. **Run the COPY command:**
**Note:** The COPY command explicitly lists only the columns from your CSV. The following columns are **automatically handled by the database** and should **NOT** be in your CSV:
- `dealer_id` - Auto-generated UUID
- `created_at` - Auto-generated timestamp
- `updated_at` - Auto-generated timestamp
- `is_active` - Defaults to `true`
```sql
\copy public.dealers(
sales_code,
service_code,
gear_code,
gma_code,
region,
dealership,
state,
district,
city,
location,
city_category_pst,
layout_format,
tier_city_category,
on_boarding_charges,
date,
single_format_month_year,
domain_id,
replacement,
termination_resignation_status,
date_of_termination_resignation,
last_date_of_operations,
old_codes,
branch_details,
dealer_principal_name,
dealer_principal_email_id,
dp_contact_number,
dp_contacts,
showroom_address,
showroom_pincode,
workshop_address,
workshop_pincode,
location_district,
state_workshop,
no_of_studios,
website_update,
gst,
pan,
firm_type,
prop_managing_partners_directors,
total_prop_partners_directors,
docs_folder_link,
workshop_gma_codes,
existing_new,
dlrcode
)
FROM 'C:/Users/COMP/Downloads/DEALERS_CLEAN.csv'
WITH (
FORMAT csv,
HEADER true,
ENCODING 'UTF8'
);
```
**What happens:**
- `dealer_id` will be automatically generated as a UUID for each row
- `created_at` will be set to the current timestamp
- `updated_at` will be set to the current timestamp
- `is_active` will default to `true`
3. **Verify import:**
```sql
SELECT COUNT(*) FROM dealers;
SELECT * FROM dealers LIMIT 5;
```
### Method 2: Using Temporary Table (If CSV has 48 columns including auto-generated ones)
**Use this if your CSV includes `dealer_id`, `created_at`, `updated_at`, `is_active` columns and you're getting errors.**
This method uses a temporary table to import the CSV, then inserts into the actual table while ignoring the auto-generated columns:
```sql
-- Step 1: Create temporary table
CREATE TEMP TABLE dealers_temp (
dealer_id TEXT,
sales_code TEXT,
service_code TEXT,
-- ... (all 48 columns as TEXT)
);
-- Step 2: Import CSV into temp table
\copy dealers_temp FROM 'C:/Users/COMP/Downloads/DEALERS_CLEAN.csv' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8');
-- Step 3: Insert into actual table (ignoring dealer_id, created_at, updated_at, is_active)
INSERT INTO public.dealers (
sales_code,
service_code,
-- ... (only the 44 data columns)
)
SELECT
NULLIF(sales_code, ''),
NULLIF(service_code, ''),
-- ... (convert and handle empty strings)
FROM dealers_temp
WHERE sales_code IS NOT NULL OR dealership IS NOT NULL; -- Skip completely empty rows
-- Step 4: Clean up
DROP TABLE dealers_temp;
```
**See `DEALERS_CSV_IMPORT_FIX.sql` for the complete working script.**
### Method 3: Using pgAdmin
1. Open pgAdmin and connect to your database
2. Right-click on `dealers` table → **Import/Export Data**
3. Select **Import**
4. Configure:
- **Filename**: Browse to your CSV file
- **Format**: CSV
- **Header**: Yes
- **Encoding**: UTF8
- **Delimiter**: Comma
5. Click **OK** to import
### Method 4: Using Node.js Script
Create a script to import CSV programmatically (useful for automation):
```typescript
import { sequelize } from '../config/database';
import { QueryTypes } from 'sequelize';
import * as fs from 'fs';
import * as path from 'path';
import * as csv from 'csv-parser';
async function importDealersFromCSV(csvFilePath: string) {
const dealers: any[] = [];
return new Promise((resolve, reject) => {
fs.createReadStream(csvFilePath)
.pipe(csv())
.on('data', (row) => {
dealers.push(row);
})
.on('end', async () => {
try {
// Bulk insert dealers
// Implementation depends on your needs
console.log(`Imported ${dealers.length} dealers`);
resolve(dealers);
} catch (error) {
reject(error);
}
});
});
}
```
---
## Troubleshooting
### Common Issues and Solutions
#### 1. **"Column count mismatch" Error**
- **Problem**: CSV has different number of columns than expected
- **Solution**:
- Verify your CSV has exactly **44 columns** (excluding header)
- **Remove** `dealer_id`, `created_at`, `updated_at`, and `is_active` if they exist in your CSV
- These columns are auto-generated and should NOT be in the CSV file
#### 2. **"Invalid date format" Error**
- **Problem**: Dates not in `YYYY-MM-DD` format
- **Solution**: Convert dates to `YYYY-MM-DD` format (e.g., `2014-09-30`)
#### 3. **"Encoding error" or "Special characters not displaying correctly**
- **Problem**: CSV file not saved in UTF-8 encoding
- **Solution**:
- In Excel: Save As → CSV UTF-8 (Comma delimited) (*.csv)
- In Notepad++: Encoding → Convert to UTF-8 → Save
#### 4. **"Permission denied" Error (COPY command)**
- **Problem**: PostgreSQL server cannot access the file path
- **Solution**:
- Use absolute path with forward slashes: `C:/Users/COMP/Downloads/DEALERS_CLEAN.csv`
- Ensure file permissions allow read access
- For remote servers, upload file to server first
#### 5. **"Duplicate key" Error**
- **Problem**: Trying to import duplicate records
- **Solution**:
- Use `ON CONFLICT` handling in your import
- Or clean CSV to remove duplicates before import
#### 6. **Empty values showing as "NULL" text**
- **Problem**: CSV contains literal "NULL" or "N/A" strings
- **Solution**: Replace with empty cells in CSV
#### 7. **Commas in address fields breaking import**
- **Problem**: Address fields contain commas not properly quoted
- **Solution**: Wrap fields containing commas in double quotes:
```csv
"No.335, HVP RR Nagar Sector B, Ideal Homes Town Ship"
```
### Pre-Import Checklist
- [ ] CSV file saved in UTF-8 encoding
- [ ] **Removed** `dealer_id`, `created_at`, `updated_at`, and `is_active` columns (if present)
- [ ] Header row matches column names exactly
- [ ] All 44 columns present in correct order
- [ ] Dates formatted as `YYYY-MM-DD`
- [ ] Numeric fields contain valid numbers (or are empty)
- [ ] Text fields with commas are wrapped in quotes
- [ ] File path is accessible from PostgreSQL server
- [ ] Database connection credentials are correct
### Verification Queries
After import, run these queries to verify:
```sql
-- Count total dealers
SELECT COUNT(*) as total_dealers FROM dealers;
-- Verify auto-generated columns
SELECT
dealer_id,
created_at,
updated_at,
is_active,
dlrcode,
dealership
FROM dealers
LIMIT 5;
-- Check for null values in key fields
SELECT
COUNT(*) FILTER (WHERE dlrcode IS NULL) as null_dlrcode,
COUNT(*) FILTER (WHERE domain_id IS NULL) as null_domain_id,
COUNT(*) FILTER (WHERE dealership IS NULL) as null_dealership
FROM dealers;
-- View sample records
SELECT
dealer_id,
dlrcode,
dealership,
city,
state,
domain_id,
created_at,
is_active
FROM dealers
LIMIT 10;
-- Check date formats
SELECT
dlrcode,
date,
date_of_termination_resignation,
last_date_of_operations
FROM dealers
WHERE date IS NOT NULL
LIMIT 5;
-- Verify all dealers have dealer_id and timestamps
SELECT
COUNT(*) as total,
COUNT(dealer_id) as has_dealer_id,
COUNT(created_at) as has_created_at,
COUNT(updated_at) as has_updated_at,
COUNT(*) FILTER (WHERE is_active = true) as active_count
FROM dealers;
```
---
## Additional Notes
- **Backup**: Always backup your database before bulk imports
- **Testing**: Test import with a small sample (5-10 rows) first
- **Validation**: Validate data quality before import
- **Updates**: Use `UPSERT` logic if you need to update existing records
---
## Support
For issues or questions:
1. Check the troubleshooting section above
2. Review PostgreSQL COPY documentation
3. Verify CSV format matches the sample provided
4. Check database logs for detailed error messages
---
**Last Updated**: December 2025
**Version**: 1.0

View File

@ -1,277 +0,0 @@
# Google Secret Manager Integration Guide
This guide explains how to integrate Google Cloud Secret Manager with your Node.js application to securely manage environment variables.
## Overview
The Google Secret Manager integration allows you to:
- Store sensitive configuration values (passwords, API keys, tokens) in Google Cloud Secret Manager
- Load secrets at application startup and merge them with your existing environment variables
- Maintain backward compatibility with `.env` files for local development
- Use minimal code changes - existing `process.env.VARIABLE_NAME` access continues to work
## Prerequisites
1. **Google Cloud Project** with Secret Manager API enabled
2. **Service Account** with Secret Manager Secret Accessor role
3. **Authentication** - Service account credentials configured (via `GCP_KEY_FILE` or default credentials)
## Setup Instructions
### 1. Enable Secret Manager API
```bash
gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_ID
```
### 2. Create Secrets in Google Secret Manager
Create secrets using the Google Cloud Console or gcloud CLI:
```bash
# Example: Create a database password secret
echo -n "your-secure-password" | gcloud secrets create DB_PASSWORD \
--project=YOUR_PROJECT_ID \
--data-file=-
# Example: Create a JWT secret
echo -n "your-jwt-secret-key" | gcloud secrets create JWT_SECRET \
--project=YOUR_PROJECT_ID \
--data-file=-
# Grant service account access to secrets
gcloud secrets add-iam-policy-binding DB_PASSWORD \
--member="serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT.iam.gserviceaccount.com" \
--role="roles/secretmanager.secretAccessor" \
--project=YOUR_PROJECT_ID
```
### 3. Configure Environment Variables
Add the following to your `.env` file:
```env
# Google Secret Manager Configuration
USE_GOOGLE_SECRET_MANAGER=true
GCP_PROJECT_ID=your-project-id
# Optional: Prefix for all secret names (e.g., "prod" -> looks for "prod-DB_PASSWORD")
GCP_SECRET_PREFIX=
# Optional: JSON file mapping secret names to env var names
GCP_SECRET_MAP_FILE=./secret-map.json
```
**Important Notes:**
- Set `USE_GOOGLE_SECRET_MANAGER=true` to enable the integration
- `GCP_PROJECT_ID` must be set (same as used for GCS/Vertex AI)
- `GCP_KEY_FILE` should already be configured for other GCP services
- When `USE_GOOGLE_SECRET_MANAGER=false` or not set, the app uses `.env` file only
### 4. Secret Name Mapping
By default, secrets in Google Secret Manager are automatically mapped to environment variables:
- Secret name: `DB_PASSWORD` → Environment variable: `DB_PASSWORD`
- Secret name: `db-password` → Environment variable: `DB_PASSWORD` (hyphens converted to underscores, uppercase)
- Secret name: `jwt-secret-key` → Environment variable: `JWT_SECRET_KEY`
#### Custom Mapping (Optional)
If you need custom mappings, create a JSON file (e.g., `secret-map.json`):
```json
{
"db-password-prod": "DB_PASSWORD",
"jwt-secret-key": "JWT_SECRET",
"okta-client-secret-prod": "OKTA_CLIENT_SECRET"
}
```
Then set in `.env`:
```env
GCP_SECRET_MAP_FILE=./secret-map.json
```
### 5. Secret Prefix (Optional)
If all your secrets share a common prefix:
```env
GCP_SECRET_PREFIX=prod
```
This will look for secrets named `prod-DB_PASSWORD`, `prod-JWT_SECRET`, etc.
## How It Works
1. **Application Startup:**
- `.env` file is loaded first (provides fallback values)
- If `USE_GOOGLE_SECRET_MANAGER=true`, secrets are fetched from Google Secret Manager
- Secrets are merged into `process.env`, overriding `.env` values if they exist
- Application continues with merged environment variables
2. **Fallback Behavior:**
- If Secret Manager is disabled or fails, the app falls back to `.env` file
- No errors are thrown - the app continues with available configuration
- Logs indicate whether secrets were loaded successfully
3. **Existing Code Compatibility:**
- No changes needed to existing code
- Continue using `process.env.VARIABLE_NAME` as before
- Secrets from GCS automatically populate `process.env`
## Default Secrets Loaded
The service automatically attempts to load these common secrets (if they exist in Secret Manager):
**Database:**
- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`
**Authentication:**
- `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `SESSION_SECRET`
**SSO/Okta:**
- `OKTA_DOMAIN`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_API_TOKEN`
**Email:**
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`
**Web Push (VAPID):**
- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`
**Logging:**
- `LOKI_HOST`, `LOKI_USER`, `LOKI_PASSWORD`
### Loading Custom Secrets
To load additional secrets, modify the code:
```typescript
// In server.ts or app.ts
import { googleSecretManager } from './services/googleSecretManager.service';
// Load default secrets + custom ones
await googleSecretManager.loadSecrets([
'DB_PASSWORD',
'JWT_SECRET',
'CUSTOM_API_KEY', // Your custom secret
'CUSTOM_SECRET_2'
]);
```
Or load a single secret on-demand:
```typescript
import { googleSecretManager } from './services/googleSecretManager.service';
const apiKey = await googleSecretManager.getSecretValue('CUSTOM_API_KEY', 'API_KEY');
```
## Security Best Practices
1. **Service Account Permissions:**
- Grant only `roles/secretmanager.secretAccessor` role
- Use separate service accounts for different environments
- Never grant `roles/owner` or `roles/editor` to service accounts
2. **Secret Rotation:**
- Rotate secrets regularly in Google Secret Manager
- The app automatically uses the `latest` version of each secret
- No code changes needed when secrets are rotated
3. **Environment Separation:**
- Use different Google Cloud projects for dev/staging/prod
- Use `GCP_SECRET_PREFIX` to namespace secrets by environment
- Never commit `.env` files with production secrets to version control
4. **Access Control:**
- Use IAM policies to control who can read secrets
- Enable audit logging for secret access
- Regularly review secret access logs
## Troubleshooting
### Secrets Not Loading
**Check logs for:**
```
[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)
[Secret Manager] GCP_PROJECT_ID not set, skipping Google Secret Manager
[Secret Manager] Failed to load secrets: [error message]
```
**Common issues:**
1. `USE_GOOGLE_SECRET_MANAGER` not set to `true`
2. `GCP_PROJECT_ID` not configured
3. Service account lacks Secret Manager permissions
4. Secrets don't exist in Secret Manager
5. Incorrect secret names (check case sensitivity)
### Service Account Authentication
Ensure service account credentials are available:
- Set `GCP_KEY_FILE` to point to service account JSON file
- Or configure Application Default Credentials (ADC)
- Test with: `gcloud auth application-default login`
### Secret Not Found
If a secret doesn't exist in Secret Manager:
- The app logs a debug message and continues
- Falls back to `.env` file value
- This is expected behavior - not all secrets need to be in GCS
### Debugging
Enable debug logging by setting:
```env
LOG_LEVEL=debug
```
This will show detailed logs about which secrets are being loaded.
## Example Configuration
**Local Development (.env):**
```env
USE_GOOGLE_SECRET_MANAGER=false
DB_PASSWORD=local-dev-password
JWT_SECRET=local-jwt-secret
```
**Production (.env):**
```env
USE_GOOGLE_SECRET_MANAGER=true
GCP_PROJECT_ID=re-platform-workflow-dealer
GCP_SECRET_PREFIX=prod
GCP_KEY_FILE=./credentials/service-account.json
# DB_PASSWORD and other secrets loaded from GCS
```
## Migration Strategy
1. **Phase 1: Setup**
- Create secrets in Google Secret Manager
- Keep `.env` file with current values (as backup)
2. **Phase 2: Test**
- Set `USE_GOOGLE_SECRET_MANAGER=true` in development
- Verify secrets are loaded correctly
- Test application functionality
3. **Phase 3: Production**
- Deploy with `USE_GOOGLE_SECRET_MANAGER=true`
- Monitor logs for secret loading success
- Remove sensitive values from `.env` file (keep placeholders)
4. **Phase 4: Cleanup**
- Remove production secrets from `.env` file
- Ensure all secrets are in Secret Manager
- Document secret names and mappings
## Additional Resources
- [Google Secret Manager Documentation](https://cloud.google.com/secret-manager/docs)
- [Secret Manager Client Library](https://cloud.google.com/nodejs/docs/reference/secret-manager/latest)
- [Service Account Best Practices](https://cloud.google.com/iam/docs/best-practices-service-accounts)

View File

@ -1,201 +0,0 @@
# Tanflow SSO User Data Mapping
This document outlines all user information available from Tanflow IAM Suite and how it maps to our User model for user creation.
## Tanflow Userinfo Endpoint Response
Tanflow uses **OpenID Connect (OIDC) standard claims** via the `/protocol/openid-connect/userinfo` endpoint. The following fields are available:
### Standard OIDC Claims (Available from Tanflow)
| Tanflow Field | OIDC Standard Claim | Type | Description | Currently Extracted |
|--------------|---------------------|------|--------------|-------------------|
| `sub` | `sub` | string | **REQUIRED** - Subject identifier (unique user ID) | ✅ Yes (as `oktaSub`) |
| `email` | `email` | string | Email address | ✅ Yes |
| `email_verified` | `email_verified` | boolean | Email verification status | ❌ No |
| `preferred_username` | `preferred_username` | string | Preferred username (fallback for email) | ✅ Yes (fallback) |
| `name` | `name` | string | Full display name | ✅ Yes (as `displayName`) |
| `given_name` | `given_name` | string | First name | ✅ Yes (as `firstName`) |
| `family_name` | `family_name` | string | Last name | ✅ Yes (as `lastName`) |
| `phone_number` | `phone_number` | string | Phone number | ✅ Yes (as `phone`) |
| `phone_number_verified` | `phone_number_verified` | boolean | Phone verification status | ❌ No |
| `address` | `address` | object | Address object (structured) | ❌ No |
| `locale` | `locale` | string | User locale (e.g., "en-US") | ❌ No |
| `picture` | `picture` | string | Profile picture URL | ❌ No |
| `website` | `website` | string | Website URL | ❌ No |
| `profile` | `profile` | string | Profile page URL | ❌ No |
| `birthdate` | `birthdate` | string | Date of birth | ❌ No |
| `gender` | `gender` | string | Gender | ❌ No |
| `zoneinfo` | `zoneinfo` | string | Timezone (e.g., "America/New_York") | ❌ No |
| `updated_at` | `updated_at` | number | Last update timestamp | ❌ No |
### Custom Tanflow Claims (May be available)
These are **custom claims** that Tanflow may include based on their configuration:
| Tanflow Field | Type | Description | Currently Extracted |
|--------------|------|-------------|-------------------|
| `employeeId` | string | Employee ID from HR system | ✅ Yes |
| `employee_id` | string | Alternative employee ID field | ✅ Yes (fallback) |
| `department` | string | Department/Division | ✅ Yes |
| `designation` | string | Job designation/position | ✅ Yes |
| `title` | string | Job title | ❌ No |
| `designation` | string | Job designation/position | ✅ Yes (as `designation`) |
| `employeeType` | string | Employee type (Dealer, Full-time, Contract, etc.) | ✅ Yes (as `jobTitle`) |
| `organization` | string | Organization name | ❌ No |
| `division` | string | Division name | ❌ No |
| `location` | string | Office location | ❌ No |
| `manager` | string | Manager name/email | ❌ No |
| `manager_id` | string | Manager employee ID | ❌ No |
| `cost_center` | string | Cost center code | ❌ No |
| `hire_date` | string | Date of hire | ❌ No |
| `office_location` | string | Office location | ❌ No |
| `country` | string | Country code | ❌ No |
| `city` | string | City name | ❌ No |
| `state` | string | State/Province | ❌ No |
| `postal_code` | string | Postal/ZIP code | ❌ No |
| `groups` | array | Group memberships | ❌ No |
| `roles` | array | User roles | ❌ No |
## Current Extraction Logic
**Location:** `Re_Backend/src/services/auth.service.ts``exchangeTanflowCodeForTokens()`
```typescript
const userData: SSOUserData = {
oktaSub: tanflowSub, // Reuse oktaSub field for Tanflow sub
email: tanflowUserInfo.email || tanflowUserInfo.preferred_username || '',
employeeId: tanflowUserInfo.employeeId || tanflowUserInfo.employee_id || undefined,
firstName: tanflowUserInfo.given_name || tanflowUserInfo.firstName || undefined,
lastName: tanflowUserInfo.family_name || tanflowUserInfo.lastName || undefined,
displayName: tanflowUserInfo.name || tanflowUserInfo.displayName || undefined,
department: tanflowUserInfo.department || undefined,
designation: tanflowUserInfo.designation || undefined, // Map designation to designation
phone: tanflowUserInfo.phone_number || tanflowUserInfo.phone || undefined,
// Additional fields
manager: tanflowUserInfo.manager || undefined,
jobTitle: tanflowUserInfo.employeeType || undefined, // Map employeeType to jobTitle
postalAddress: tanflowUserInfo.address ? (typeof tanflowUserInfo.address === 'string' ? tanflowUserInfo.address : JSON.stringify(tanflowUserInfo.address)) : undefined,
mobilePhone: tanflowUserInfo.mobile_phone || tanflowUserInfo.mobilePhone || undefined,
adGroups: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups : undefined,
};
```
## User Model Fields Mapping
**Location:** `Re_Backend/src/models/User.ts`
| User Model Field | Tanflow Source | Required | Notes |
|-----------------|----------------|----------|-------|
| `userId` | Auto-generated UUID | ✅ | Primary key |
| `oktaSub` | `sub` | ✅ | Unique identifier from Tanflow |
| `email` | `email` or `preferred_username` | ✅ | Primary identifier |
| `employeeId` | `employeeId` or `employee_id` | ❌ | Optional HR system ID |
| `firstName` | `given_name` or `firstName` | ❌ | Optional |
| `lastName` | `family_name` or `lastName` | ❌ | Optional |
| `displayName` | `name` or `displayName` | ❌ | Auto-generated if missing |
| `department` | `department` | ❌ | Optional |
| `designation` | `designation` | ❌ | Optional |
| `phone` | `phone_number` or `phone` | ❌ | Optional |
| `manager` | `manager` | ❌ | Optional (extracted if available) |
| `secondEmail` | N/A | ❌ | Not available from Tanflow |
| `jobTitle` | `employeeType` | ❌ | Optional (maps employeeType to jobTitle) |
| `employeeNumber` | N/A | ❌ | Not available from Tanflow |
| `postalAddress` | `address` (structured) | ❌ | **NOT currently extracted** |
| `mobilePhone` | N/A | ❌ | Not available from Tanflow |
| `adGroups` | `groups` | ❌ | **NOT currently extracted** |
| `location` | `address`, `city`, `state`, `country` | ❌ | **NOT currently extracted** |
| `role` | Default: 'USER' | ✅ | Default role assigned |
| `isActive` | Default: true | ✅ | Auto-set to true |
| `lastLogin` | Current timestamp | ✅ | Auto-set on login |
## Recommended Enhancements
### 1. Extract Additional Fields
Consider extracting these fields if available from Tanflow:
```typescript
// Enhanced extraction (to be implemented)
const userData: SSOUserData = {
// ... existing fields ...
// Additional fields (already implemented)
manager: tanflowUserInfo.manager || undefined,
jobTitle: tanflowUserInfo.employeeType || undefined, // Map employeeType to jobTitle
postalAddress: tanflowUserInfo.address ? (typeof tanflowUserInfo.address === 'string' ? tanflowUserInfo.address : JSON.stringify(tanflowUserInfo.address)) : undefined,
mobilePhone: tanflowUserInfo.mobile_phone || tanflowUserInfo.mobilePhone || undefined,
adGroups: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups : undefined,
// Location object
location: {
city: tanflowUserInfo.city || undefined,
state: tanflowUserInfo.state || undefined,
country: tanflowUserInfo.country || undefined,
office: tanflowUserInfo.office_location || undefined,
timezone: tanflowUserInfo.zoneinfo || undefined,
},
};
```
### 2. Log Available Fields
Add logging to see what Tanflow actually returns:
```typescript
logger.info('Tanflow userinfo response', {
availableFields: Object.keys(tanflowUserInfo),
hasEmail: !!tanflowUserInfo.email,
hasEmployeeId: !!(tanflowUserInfo.employeeId || tanflowUserInfo.employee_id),
hasDepartment: !!tanflowUserInfo.department,
hasManager: !!tanflowUserInfo.manager,
hasGroups: Array.isArray(tanflowUserInfo.groups),
groupsCount: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups.length : 0,
sampleData: {
sub: tanflowUserInfo.sub?.substring(0, 10) + '...',
email: tanflowUserInfo.email?.substring(0, 10) + '...',
name: tanflowUserInfo.name,
}
});
```
## User Creation Flow
1. **Token Exchange** → Get `access_token` from Tanflow
2. **Userinfo Call** → Call `/protocol/openid-connect/userinfo` with `access_token`
3. **Extract Data** → Map Tanflow fields to `SSOUserData` interface
4. **User Lookup** → Check if user exists by `email`
5. **Create/Update** → Create new user or update existing user
6. **Generate Tokens** → Generate JWT access/refresh tokens
## Testing Recommendations
1. **Test with Real Tanflow Account**
- Log actual userinfo response
- Document all available fields
- Verify field mappings
2. **Handle Missing Fields**
- Ensure graceful fallbacks
- Don't fail if optional fields are missing
- Log warnings for missing expected fields
3. **Validate Required Fields**
- `sub` (oktaSub) - REQUIRED
- `email` or `preferred_username` - REQUIRED
## Next Steps
1. ✅ **Current Implementation** - Basic OIDC claims extraction
2. 🔄 **Enhancement** - Extract additional custom claims (manager, groups, location)
3. 🔄 **Logging** - Add detailed logging of Tanflow response
4. 🔄 **Testing** - Test with real Tanflow account to see actual fields
5. 🔄 **Documentation** - Update this doc with actual Tanflow response structure
## Notes
- Tanflow uses **Keycloak** under the hood (based on URL structure)
- Keycloak supports custom user attributes that may be available
- Some fields may require specific realm/client configuration in Tanflow
- Contact Tanflow support to confirm available custom claims

View File

@ -30,13 +30,6 @@ GCP_PROJECT_ID=re-workflow-project
GCP_BUCKET_NAME=re-workflow-documents GCP_BUCKET_NAME=re-workflow-documents
GCP_KEY_FILE=./config/gcp-key.json GCP_KEY_FILE=./config/gcp-key.json
# Google Secret Manager (Optional - for production)
# Set USE_GOOGLE_SECRET_MANAGER=true to enable loading secrets from Google Secret Manager
# Secrets from GCS will override .env file values
USE_GOOGLE_SECRET_MANAGER=false
# GCP_SECRET_PREFIX=optional-prefix-for-secret-names (e.g., "prod" -> looks for "prod-DB_PASSWORD")
# GCP_SECRET_MAP_FILE=./secret-map.json (optional JSON file to map secret names to env var names)
# Email Service (Optional) # Email Service (Optional)
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587

395
package-lock.json generated
View File

@ -8,7 +8,6 @@
"name": "re-workflow-backend", "name": "re-workflow-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@google-cloud/secret-manager": "^6.1.1",
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",
"@google-cloud/vertexai": "^1.10.0", "@google-cloud/vertexai": "^1.10.0",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",
@ -1615,18 +1614,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@google-cloud/secret-manager": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.1.tgz",
"integrity": "sha512-dwSuxJ9RNmAW46FjK1StiNIeOiSHHQs/XIy4VArJ6bBMR+WsIvR+zhPh2pa40aFa9uTty67j38Rl268TVV62EA==",
"license": "Apache-2.0",
"dependencies": {
"google-gax": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@google-cloud/storage": { "node_modules/@google-cloud/storage": {
"version": "7.18.0", "version": "7.18.0",
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz", "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz",
@ -1695,37 +1682,6 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
},
"engines": {
"node": ">=12.10.0"
}
},
"node_modules/@grpc/proto-loader": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz",
"integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==",
"license": "Apache-2.0",
"dependencies": {
"lodash.camelcase": "^4.3.0",
"long": "^5.0.0",
"protobufjs": "^7.5.3",
"yargs": "^17.7.2"
},
"bin": {
"proto-loader-gen-types": "build/bin/proto-loader-gen-types.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1788,6 +1744,7 @@
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^5.1.2", "string-width": "^5.1.2",
@ -1805,6 +1762,7 @@
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -1817,6 +1775,7 @@
"version": "6.2.3", "version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -1829,12 +1788,14 @@
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@isaacs/cliui/node_modules/string-width": { "node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"eastasianwidth": "^0.2.0", "eastasianwidth": "^0.2.0",
@ -1852,6 +1813,7 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^6.0.1" "ansi-regex": "^6.0.1"
@ -1867,6 +1829,7 @@
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^6.1.0", "ansi-styles": "^6.1.0",
@ -2339,16 +2302,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@js-sdsl/ordered-map": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz",
"integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
@ -2808,6 +2761,7 @@
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"engines": { "engines": {
@ -4394,6 +4348,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -4403,6 +4358,7 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@ -4673,6 +4629,7 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": { "node_modules/base64-js": {
@ -4828,6 +4785,7 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@ -5137,6 +5095,7 @@
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^4.2.0", "string-width": "^4.2.0",
@ -5191,6 +5150,7 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@ -5203,6 +5163,7 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/color-string": { "node_modules/color-string": {
@ -5478,6 +5439,7 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@ -5488,15 +5450,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/dayjs": { "node_modules/dayjs": {
"version": "1.11.19", "version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@ -5711,6 +5664,7 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
@ -5787,6 +5741,7 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/enabled": { "node_modules/enabled": {
@ -5948,6 +5903,7 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -6452,29 +6408,6 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@ -6602,6 +6535,7 @@
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
@ -6618,6 +6552,7 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=14" "node": ">=14"
@ -6642,18 +6577,6 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/formidable": { "node_modules/formidable": {
"version": "3.5.4", "version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
@ -6794,6 +6717,7 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@ -6992,203 +6916,6 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/google-gax": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.6.tgz",
"integrity": "sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.12.6",
"@grpc/proto-loader": "^0.8.0",
"duplexify": "^4.1.3",
"google-auth-library": "^10.1.0",
"google-logging-utils": "^1.1.1",
"node-fetch": "^3.3.2",
"object-hash": "^3.0.0",
"proto3-json-serializer": "^3.0.0",
"protobufjs": "^7.5.3",
"retry-request": "^8.0.0",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/google-gax/node_modules/gaxios": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz",
"integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2",
"rimraf": "^5.0.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/google-gax/node_modules/google-auth-library": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz",
"integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.0.0",
"gcp-metadata": "^8.0.0",
"google-logging-utils": "^1.0.0",
"gtoken": "^8.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/google-gax/node_modules/gtoken": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
"license": "MIT",
"dependencies": {
"gaxios": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/google-gax/node_modules/retry-request": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz",
"integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2",
"teeny-request": "^10.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/rimraf": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
"integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
"license": "ISC",
"dependencies": {
"glob": "^10.3.7"
},
"bin": {
"rimraf": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/google-gax/node_modules/teeny-request": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz",
"integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==",
"license": "Apache-2.0",
"dependencies": {
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0",
"node-fetch": "^3.3.2",
"stream-events": "^1.0.5"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-gax/node_modules/teeny-request/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/google-logging-utils": { "node_modules/google-logging-utils": {
"version": "0.0.2", "version": "0.0.2",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
@ -7607,6 +7334,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -7667,6 +7395,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": { "node_modules/istanbul-lib-coverage": {
@ -7744,6 +7473,7 @@
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/cliui": "^8.0.2" "@isaacs/cliui": "^8.0.2"
@ -8630,12 +8360,6 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
},
"node_modules/lodash.defaults": { "node_modules/lodash.defaults": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -8899,6 +8623,7 @@
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"
@ -8923,6 +8648,7 @@
"version": "7.1.2", "version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@ -9115,26 +8841,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -9317,15 +9023,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -9477,6 +9174,7 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/parent-module": { "node_modules/parent-module": {
@ -9580,6 +9278,7 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -9596,6 +9295,7 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@ -9612,6 +9312,7 @@
"version": "10.4.3", "version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
@ -9981,18 +9682,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/proto3-json-serializer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz",
"integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==",
"license": "Apache-2.0",
"dependencies": {
"protobufjs": "^7.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.5.4", "version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@ -10199,6 +9888,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -10623,6 +10313,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@ -10635,6 +10326,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -11030,6 +10722,7 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@ -11045,6 +10738,7 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@ -11059,6 +10753,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@ -11072,6 +10767,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@ -11924,15 +11620,6 @@
"node": ">= 16" "node": ">= 16"
} }
}, },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@ -11953,6 +11640,7 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@ -12046,6 +11734,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@ -12064,6 +11753,7 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@ -12133,6 +11823,7 @@
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -12149,6 +11840,7 @@
"version": "17.7.2", "version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cliui": "^8.0.1", "cliui": "^8.0.1",
@ -12167,6 +11859,7 @@
"version": "21.1.1", "version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">=12" "node": ">=12"

View File

@ -4,8 +4,8 @@
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)", "description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
"start": "npm run build && npm run start:prod && npm run setup", "start": "npm run setup && npm run build && npm run start:prod",
"dev": "npm run setup && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "dev": "npm run setup && npm run migrate && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts", "dev:no-setup": "nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
"build": "tsc && tsc-alias", "build": "tsc && tsc-alias",
"build:watch": "tsc --watch", "build:watch": "tsc --watch",
@ -18,11 +18,10 @@
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts", "setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts",
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.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:test-dealer": "ts-node -r tsconfig-paths/register src/scripts/seed-test-dealer.ts", "seed:dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-dealers.ts",
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts" "cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
}, },
"dependencies": { "dependencies": {
"@google-cloud/secret-manager": "^6.1.1",
"@google-cloud/storage": "^7.18.0", "@google-cloud/storage": "^7.18.0",
"@google-cloud/vertexai": "^1.10.0", "@google-cloud/vertexai": "^1.10.0",
"@types/nodemailer": "^7.0.4", "@types/nodemailer": "^7.0.4",

View File

@ -1,11 +0,0 @@
{
"_comment": "Optional: Map Google Secret Manager secret names to environment variable names",
"_comment2": "If not provided, secrets are mapped automatically: secret-name -> SECRET_NAME (uppercase)",
"examples": {
"db-password": "DB_PASSWORD",
"jwt-secret-key": "JWT_SECRET",
"okta-client-secret": "OKTA_CLIENT_SECRET"
}
}

View File

@ -10,14 +10,11 @@ import { corsMiddleware } from './middlewares/cors.middleware';
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware'; import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
import routes from './routes/index'; import routes from './routes/index';
import { ensureUploadDir, UPLOAD_DIR } from './config/storage'; import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
import path from 'path'; import path from 'path';
// Load environment variables from .env file first // Load environment variables
dotenv.config(); dotenv.config();
// Secrets are now initialized in server.ts before app is imported
const app: express.Application = express(); const app: express.Application = express();
const userService = new UserService(); const userService = new UserService();

View File

@ -1,25 +1,17 @@
import { SSOConfig, SSOUserData } from '../types/auth.types'; import { SSOConfig, SSOUserData } from '../types/auth.types';
// Use getter functions to read from process.env dynamically
// This ensures values are read after secrets are loaded from Google Secret Manager
const ssoConfig: SSOConfig = { const ssoConfig: SSOConfig = {
get jwtSecret() { return process.env.JWT_SECRET || ''; }, jwtSecret: process.env.JWT_SECRET || '',
get jwtExpiry() { return process.env.JWT_EXPIRY || '24h'; }, jwtExpiry: process.env.JWT_EXPIRY || '24h',
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; }, refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY || '7d',
get sessionSecret() { return process.env.SESSION_SECRET || ''; }, sessionSecret: process.env.SESSION_SECRET || '',
// Use only FRONTEND_URL from environment - no fallbacks // Use only FRONTEND_URL from environment - no fallbacks
get allowedOrigins() { allowedOrigins: process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [],
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
},
// Okta/Auth0 configuration for token exchange // Okta/Auth0 configuration for token exchange
get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; }, oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com',
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; }, oktaClientId: process.env.OKTA_CLIENT_ID || '',
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; }, oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API
// Tanflow configuration for token exchange
get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; },
get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; },
get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; },
}; };
export { ssoConfig }; export { ssoConfig };

View File

@ -1,7 +1,6 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Holiday, HolidayType } from '@models/Holiday'; import { Holiday, HolidayType } from '@models/Holiday';
import { holidayService } from '@services/holiday.service'; import { holidayService } from '@services/holiday.service';
import { activityTypeService } from '@services/activityType.service';
import { sequelize } from '@config/database'; import { sequelize } from '@config/database';
import { QueryTypes, Op } from 'sequelize'; import { QueryTypes, Op } from 'sequelize';
import logger from '@utils/logger'; import logger from '@utils/logger';
@ -250,7 +249,7 @@ export const getPublicConfigurations = async (req: Request, res: Response): Prom
const { category } = req.query; const { category } = req.query;
// Only allow certain categories for public access // Only allow certain categories for public access
const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING', 'SYSTEM_SETTINGS']; const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING'];
if (category && !allowedCategories.includes(category as string)) { if (category && !allowedCategories.includes(category as string)) {
res.status(403).json({ res.status(403).json({
success: false, success: false,
@ -263,7 +262,7 @@ export const getPublicConfigurations = async (req: Request, res: Response): Prom
if (category) { if (category) {
whereClause = `WHERE config_category = '${category}' AND is_sensitive = false`; whereClause = `WHERE config_category = '${category}' AND is_sensitive = false`;
} else { } else {
whereClause = `WHERE config_category IN ('DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING', 'SYSTEM_SETTINGS') AND is_sensitive = false`; whereClause = `WHERE config_category IN ('DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING') AND is_sensitive = false`;
} }
const rawConfigurations = await sequelize.query(` const rawConfigurations = await sequelize.query(`
@ -879,174 +878,3 @@ export const assignRoleByEmail = async (req: Request, res: Response): Promise<vo
} }
}; };
// ==================== Activity Type Management Routes ====================
/**
* Get all activity types (optionally filtered by active status)
*/
export const getAllActivityTypes = async (req: Request, res: Response): Promise<void> => {
try {
const { activeOnly } = req.query;
const activeOnlyBool = activeOnly === 'true';
const activityTypes = await activityTypeService.getAllActivityTypes(activeOnlyBool);
res.json({
success: true,
data: activityTypes,
count: activityTypes.length
});
} catch (error: any) {
logger.error('[Admin] Error fetching activity types:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch activity types'
});
}
};
/**
* Get a single activity type by ID
*/
export const getActivityTypeById = async (req: Request, res: Response): Promise<void> => {
try {
const { activityTypeId } = req.params;
const activityType = await activityTypeService.getActivityTypeById(activityTypeId);
if (!activityType) {
res.status(404).json({
success: false,
error: 'Activity type not found'
});
return;
}
res.json({
success: true,
data: activityType
});
} catch (error: any) {
logger.error('[Admin] Error fetching activity type:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to fetch activity type'
});
}
};
/**
* Create a new activity type
*/
export const createActivityType = async (req: Request, res: Response): Promise<void> => {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
error: 'User not authenticated'
});
return;
}
const {
title,
itemCode,
taxationType,
sapRefNo
} = req.body;
// Validate required fields
if (!title) {
res.status(400).json({
success: false,
error: 'Activity type title is required'
});
return;
}
const activityType = await activityTypeService.createActivityType({
title,
itemCode: itemCode || null,
taxationType: taxationType || null,
sapRefNo: sapRefNo || null,
createdBy: userId
});
res.status(201).json({
success: true,
message: 'Activity type created successfully',
data: activityType
});
} catch (error: any) {
logger.error('[Admin] Error creating activity type:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to create activity type'
});
}
};
/**
* Update an activity type
*/
export const updateActivityType = async (req: Request, res: Response): Promise<void> => {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
success: false,
error: 'User not authenticated'
});
return;
}
const { activityTypeId } = req.params;
const updates = req.body;
const activityType = await activityTypeService.updateActivityType(activityTypeId, updates, userId);
if (!activityType) {
res.status(404).json({
success: false,
error: 'Activity type not found'
});
return;
}
res.json({
success: true,
message: 'Activity type updated successfully',
data: activityType
});
} catch (error: any) {
logger.error('[Admin] Error updating activity type:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to update activity type'
});
}
};
/**
* Delete (deactivate) an activity type
*/
export const deleteActivityType = async (req: Request, res: Response): Promise<void> => {
try {
const { activityTypeId } = req.params;
await activityTypeService.deleteActivityType(activityTypeId);
res.json({
success: true,
message: 'Activity type deleted successfully'
});
} catch (error: any) {
logger.error('[Admin] Error deleting activity type:', error);
res.status(500).json({
success: false,
error: error.message || 'Failed to delete activity type'
});
}
};

View File

@ -1,15 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ApprovalService } from '@services/approval.service'; import { ApprovalService } from '@services/approval.service';
import { DealerClaimApprovalService } from '@services/dealerClaimApproval.service';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { WorkflowRequest } from '@models/WorkflowRequest';
import { validateApprovalAction } from '@validators/approval.validator'; import { validateApprovalAction } from '@validators/approval.validator';
import { ResponseHandler } from '@utils/responseHandler'; import { ResponseHandler } from '@utils/responseHandler';
import type { AuthenticatedRequest } from '../types/express'; import type { AuthenticatedRequest } from '../types/express';
import { getRequestMetadata } from '@utils/requestUtils'; import { getRequestMetadata } from '@utils/requestUtils';
const approvalService = new ApprovalService(); const approvalService = new ApprovalService();
const dealerClaimApprovalService = new DealerClaimApprovalService();
export class ApprovalController { export class ApprovalController {
async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> { async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> {
@ -17,54 +13,18 @@ export class ApprovalController {
const { levelId } = req.params; const { levelId } = req.params;
const validatedData = validateApprovalAction(req.body); const validatedData = validateApprovalAction(req.body);
// Determine which service to use based on workflow type const requestMeta = getRequestMetadata(req);
const level = await ApprovalLevel.findByPk(levelId); const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId, {
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
});
if (!level) { if (!level) {
ResponseHandler.notFound(res, 'Approval level not found'); ResponseHandler.notFound(res, 'Approval level not found');
return; return;
} }
const workflow = await WorkflowRequest.findByPk(level.requestId); ResponseHandler.success(res, level, 'Approval level updated successfully');
if (!workflow) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const workflowType = (workflow as any)?.workflowType;
const requestMeta = getRequestMetadata(req);
// Route to appropriate service based on workflow type
let approvedLevel: any;
if (workflowType === 'CLAIM_MANAGEMENT') {
// Use DealerClaimApprovalService for claim management workflows
approvedLevel = await dealerClaimApprovalService.approveLevel(
levelId,
validatedData,
req.user.userId,
{
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
}
);
} else {
// Use ApprovalService for custom workflows
approvedLevel = await approvalService.approveLevel(
levelId,
validatedData,
req.user.userId,
{
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent
}
);
}
if (!approvedLevel) {
ResponseHandler.notFound(res, 'Approval level not found');
return;
}
ResponseHandler.success(res, approvedLevel, 'Approval level updated successfully');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage); ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage);
@ -74,23 +34,7 @@ export class ApprovalController {
async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> { async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params; const { id } = req.params;
const level = await approvalService.getCurrentApprovalLevel(id);
// Determine which service to use based on workflow type
const workflow = await WorkflowRequest.findByPk(id);
if (!workflow) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const workflowType = (workflow as any)?.workflowType;
// Route to appropriate service based on workflow type
let level: any;
if (workflowType === 'CLAIM_MANAGEMENT') {
level = await dealerClaimApprovalService.getCurrentApprovalLevel(id);
} else {
level = await approvalService.getCurrentApprovalLevel(id);
}
ResponseHandler.success(res, level, 'Current approval level retrieved successfully'); ResponseHandler.success(res, level, 'Current approval level retrieved successfully');
} catch (error) { } catch (error) {
@ -102,23 +46,7 @@ export class ApprovalController {
async getApprovalLevels(req: Request, res: Response): Promise<void> { async getApprovalLevels(req: Request, res: Response): Promise<void> {
try { try {
const { id } = req.params; const { id } = req.params;
const levels = await approvalService.getApprovalLevels(id);
// Determine which service to use based on workflow type
const workflow = await WorkflowRequest.findByPk(id);
if (!workflow) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const workflowType = (workflow as any)?.workflowType;
// Route to appropriate service based on workflow type
let levels: any[];
if (workflowType === 'CLAIM_MANAGEMENT') {
levels = await dealerClaimApprovalService.getApprovalLevels(id);
} else {
levels = await approvalService.getApprovalLevels(id);
}
ResponseHandler.success(res, levels, 'Approval levels retrieved successfully'); ResponseHandler.success(res, levels, 'Approval levels retrieved successfully');
} catch (error) { } catch (error) {

View File

@ -84,7 +84,6 @@ export class AuthController {
displayName: user.displayName, displayName: user.displayName,
department: user.department, department: user.department,
designation: user.designation, designation: user.designation,
jobTitle: user.jobTitle,
phone: user.phone, phone: user.phone,
location: user.location, location: user.location,
role: user.role, role: user.role,
@ -160,128 +159,6 @@ export class AuthController {
} }
} }
/**
* Exchange Tanflow authorization code for tokens
* POST /api/v1/auth/tanflow/token-exchange
*/
async exchangeTanflowToken(req: Request, res: Response): Promise<void> {
try {
logger.info('Tanflow token exchange request received', {
body: {
code: req.body?.code ? `${req.body.code.substring(0, 10)}...` : 'MISSING',
redirectUri: req.body?.redirectUri,
state: req.body?.state ? 'PRESENT' : 'MISSING',
},
});
const { code, redirectUri } = validateTokenExchange(req.body);
logger.info('Tanflow token exchange validation passed', { redirectUri });
const result = await this.authService.exchangeTanflowCodeForTokens(code, redirectUri);
// Log login activity
const requestMeta = getRequestMetadata(req);
await activityService.log({
requestId: SYSTEM_EVENT_REQUEST_ID,
type: 'login',
user: {
userId: result.user.userId,
name: result.user.displayName || result.user.email,
email: result.user.email
},
timestamp: new Date().toISOString(),
action: 'User Login',
details: `User logged in via Tanflow SSO from ${requestMeta.ipAddress || 'unknown IP'}`,
metadata: {
loginMethod: 'TANFLOW_SSO',
employeeId: result.user.employeeId,
department: result.user.department,
role: result.user.role
},
ipAddress: requestMeta.ipAddress,
userAgent: requestMeta.userAgent,
category: 'AUTHENTICATION',
severity: 'INFO'
});
// Set tokens in httpOnly cookies (production) or return in body (development)
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000, // 24 hours
path: '/',
};
res.cookie('accessToken', result.accessToken, cookieOptions);
res.cookie('refreshToken', result.refreshToken, cookieOptions);
// In production, don't return tokens in response body (security)
// In development, include tokens for cross-port setup
if (isProduction) {
ResponseHandler.success(res, {
user: result.user,
idToken: result.oktaIdToken, // Include id_token for Tanflow logout
}, 'Authentication successful');
} else {
ResponseHandler.success(res, {
user: result.user,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
idToken: result.oktaIdToken,
}, 'Authentication successful');
}
} catch (error) {
logger.error('Tanflow token exchange failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Tanflow authentication failed', 400, errorMessage);
}
}
/**
* Refresh Tanflow access token
* POST /api/v1/auth/tanflow/refresh
*/
async refreshTanflowToken(req: Request, res: Response): Promise<void> {
try {
const refreshToken = req.body?.refreshToken;
if (!refreshToken) {
ResponseHandler.error(res, 'Refresh token is required', 400, 'Refresh token is required in request body');
return;
}
const newAccessToken = await this.authService.refreshTanflowToken(refreshToken);
// Set new access token in cookie
const isProduction = process.env.NODE_ENV === 'production';
const cookieOptions = {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? ('none' as const) : ('lax' as const),
maxAge: 24 * 60 * 60 * 1000,
path: '/',
};
res.cookie('accessToken', newAccessToken, cookieOptions);
if (isProduction) {
ResponseHandler.success(res, {
message: 'Token refreshed successfully'
}, 'Token refreshed successfully');
} else {
ResponseHandler.success(res, {
accessToken: newAccessToken
}, 'Token refreshed successfully');
}
} catch (error) {
logger.error('Tanflow token refresh failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
ResponseHandler.error(res, 'Token refresh failed', 401, errorMessage);
}
}
/** /**
* Logout user * Logout user
* POST /api/v1/auth/logout * POST /api/v1/auth/logout

View File

@ -79,7 +79,7 @@ export class ConclusionController {
const workNotes = await WorkNote.findAll({ const workNotes = await WorkNote.findAll({
where: { requestId }, where: { requestId },
order: [['createdAt', 'ASC']], order: [['createdAt', 'ASC']],
limit: 20 // Last 20 work notes - keep full context for better conclusions limit: 20 // Last 20 work notes
}); });
const documents = await Document.findAll({ const documents = await Document.findAll({
@ -90,7 +90,7 @@ export class ConclusionController {
const activities = await Activity.findAll({ const activities = await Activity.findAll({
where: { requestId }, where: { requestId },
order: [['createdAt', 'ASC']], order: [['createdAt', 'ASC']],
limit: 50 // Last 50 activities - keep full context for better conclusions limit: 50 // Last 50 activities
}); });
// Build context object // Build context object

View File

@ -46,7 +46,6 @@ export class DashboardController {
const endDate = req.query.endDate as string | undefined; const endDate = req.query.endDate as string | undefined;
const status = req.query.status as string | undefined; // Status filter (not used in stats - stats show all statuses) const status = req.query.status as string | undefined; // Status filter (not used in stats - stats show all statuses)
const priority = req.query.priority as string | undefined; const priority = req.query.priority as string | undefined;
const templateType = req.query.templateType as string | undefined;
const department = req.query.department as string | undefined; const department = req.query.department as string | undefined;
const initiator = req.query.initiator as string | undefined; const initiator = req.query.initiator as string | undefined;
const approver = req.query.approver as string | undefined; const approver = req.query.approver as string | undefined;
@ -62,7 +61,6 @@ export class DashboardController {
endDate, endDate,
status, status,
priority, priority,
templateType,
department, department,
initiator, initiator,
approver, approver,

View File

@ -7,15 +7,11 @@ import logger from '../utils/logger';
export class DealerController { export class DealerController {
/** /**
* Get all dealers * Get all dealers
* GET /api/v1/dealers?q=searchTerm&limit=10 (optional search and limit) * GET /api/v1/dealers
*/ */
async getAllDealers(req: Request, res: Response): Promise<void> { async getAllDealers(req: Request, res: Response): Promise<void> {
try { try {
const searchTerm = req.query.q as string | undefined; const dealers = await dealerService.getAllDealers();
const limitParam = req.query.limit as string | undefined;
// Parse limit, default to 10, max 100
const limit = limitParam ? Math.min(Math.max(1, parseInt(limitParam, 10)), 100) : 10;
const dealers = await dealerService.getAllDealers(searchTerm, limit);
return ResponseHandler.success(res, dealers, 'Dealers fetched successfully'); return ResponseHandler.success(res, dealers, 'Dealers fetched successfully');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -68,19 +64,17 @@ export class DealerController {
/** /**
* Search dealers * Search dealers
* GET /api/v1/dealers/search?q=searchTerm&limit=10 * GET /api/v1/dealers/search?q=searchTerm
*/ */
async searchDealers(req: Request, res: Response): Promise<void> { async searchDealers(req: Request, res: Response): Promise<void> {
try { try {
const { q, limit: limitParam } = req.query; const { q } = req.query;
if (!q || typeof q !== 'string') { if (!q || typeof q !== 'string') {
return ResponseHandler.error(res, 'Search term is required', 400); return ResponseHandler.error(res, 'Search term is required', 400);
} }
// Parse limit, default to 10, max 100 const dealers = await dealerService.searchDealers(q);
const limit = limitParam ? Math.min(Math.max(1, parseInt(limitParam as string, 10)), 100) : 10;
const dealers = await dealerService.searchDealers(q, limit);
return ResponseHandler.success(res, dealers, 'Dealers searched successfully'); return ResponseHandler.success(res, dealers, 'Dealers searched successfully');
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -88,36 +82,5 @@ export class DealerController {
return ResponseHandler.error(res, 'Failed to search dealers', 500, errorMessage); return ResponseHandler.error(res, 'Failed to search dealers', 500, errorMessage);
} }
} }
/**
* Verify dealer is logged in
* GET /api/v1/dealers/verify/:dealerCode
* Returns dealer info with isLoggedIn flag
*/
async verifyDealerLogin(req: Request, res: Response): Promise<void> {
try {
const { dealerCode } = req.params;
const dealer = await dealerService.getDealerByCode(dealerCode);
if (!dealer) {
return ResponseHandler.error(res, 'Dealer not found', 404);
}
if (!dealer.isLoggedIn) {
return ResponseHandler.error(
res,
'Dealer not logged in to the system',
400,
`The dealer with code ${dealerCode} (${dealer.email}) has not logged in to the system. Please ask them to log in first.`
);
}
return ResponseHandler.success(res, dealer, 'Dealer verified and logged in');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[DealerController] Error verifying dealer login:', error);
return ResponseHandler.error(res, 'Failed to verify dealer', 500, errorMessage);
}
}
} }

View File

@ -250,7 +250,6 @@ export class DealerClaimController {
numberOfParticipants, numberOfParticipants,
closedExpenses, closedExpenses,
totalClosedExpenses, totalClosedExpenses,
completionDescription,
} = req.body; } = req.body;
// Parse closedExpenses if it's a JSON string // Parse closedExpenses if it's a JSON string
@ -541,7 +540,6 @@ export class DealerClaimController {
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0, totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined, invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined, attendanceSheet: attendanceSheet || undefined,
completionDescription: completionDescription || undefined,
}); });
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted'); return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
@ -840,88 +838,5 @@ export class DealerClaimController {
return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage); return ResponseHandler.error(res, 'Failed to send credit note to dealer', 500, errorMessage);
} }
} }
/**
* Test SAP Budget Blocking (for testing/debugging)
* POST /api/v1/dealer-claims/test/sap-block
*
* This endpoint allows direct testing of SAP budget blocking without creating a full request
*/
async testSapBudgetBlock(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const userId = req.user?.userId;
if (!userId) {
return ResponseHandler.error(res, 'Unauthorized', 401);
}
const { ioNumber, amount, requestNumber } = req.body;
// Validation
if (!ioNumber || !amount) {
return ResponseHandler.error(res, 'Missing required fields: ioNumber and amount are required', 400);
}
const blockAmount = parseFloat(amount);
if (isNaN(blockAmount) || blockAmount <= 0) {
return ResponseHandler.error(res, 'Amount must be a positive number', 400);
}
logger.info(`[DealerClaimController] Testing SAP budget block:`, {
ioNumber,
amount: blockAmount,
requestNumber: requestNumber || 'TEST-REQUEST',
userId
});
// First validate IO number
const ioValidation = await sapIntegrationService.validateIONumber(ioNumber);
if (!ioValidation.isValid) {
return ResponseHandler.error(res, `Invalid IO number: ${ioValidation.error || 'IO number not found in SAP'}`, 400);
}
logger.info(`[DealerClaimController] IO validation successful:`, {
ioNumber,
availableBalance: ioValidation.availableBalance
});
// Block budget in SAP
const testRequestNumber = requestNumber || `TEST-${Date.now()}`;
const blockResult = await sapIntegrationService.blockBudget(
ioNumber,
blockAmount,
testRequestNumber,
`Test budget block for ${testRequestNumber}`
);
if (!blockResult.success) {
return ResponseHandler.error(res, `Failed to block budget in SAP: ${blockResult.error}`, 500);
}
// Return detailed response
return ResponseHandler.success(res, {
message: 'SAP budget block test successful',
ioNumber,
requestedAmount: blockAmount,
availableBalance: ioValidation.availableBalance,
sapResponse: {
success: blockResult.success,
blockedAmount: blockResult.blockedAmount,
remainingBalance: blockResult.remainingBalance,
sapDocumentNumber: blockResult.blockId || null,
error: blockResult.error || null
},
calculatedRemainingBalance: ioValidation.availableBalance - blockResult.blockedAmount,
validation: {
isValid: ioValidation.isValid,
availableBalance: ioValidation.availableBalance,
error: ioValidation.error || null
}
}, 'SAP budget block test completed');
} catch (error: any) {
logger.error('[DealerClaimController] Error testing SAP budget block:', error);
return ResponseHandler.error(res, error.message || 'Failed to test SAP budget block', 500);
}
}
} }

View File

@ -1,39 +0,0 @@
import { Request, Response } from 'express';
import { dealerDashboardService } from '../services/dealerDashboard.service';
import logger from '@utils/logger';
export class DealerDashboardController {
/**
* Get dealer dashboard KPIs and category data
* GET /api/v1/dealer-claims/dashboard
*/
async getDashboard(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user?.userId;
const userEmail = (req as any).user?.email;
const dateRange = req.query.dateRange as string | undefined;
const startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined;
const result = await dealerDashboardService.getDashboardKPIs(
userEmail,
userId,
dateRange,
startDate,
endDate
);
res.json({
success: true,
data: result
});
} catch (error) {
logger.error('[DealerDashboard] Error fetching dashboard:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch dealer dashboard data'
});
}
}
}

View File

@ -5,14 +5,9 @@ import fs from 'fs';
import { Document } from '@models/Document'; import { Document } from '@models/Document';
import { User } from '@models/User'; import { User } from '@models/User';
import { WorkflowRequest } from '@models/WorkflowRequest'; import { WorkflowRequest } from '@models/WorkflowRequest';
import { Participant } from '@models/Participant';
import { ApprovalLevel } from '@models/ApprovalLevel';
import { Op } from 'sequelize';
import { ResponseHandler } from '@utils/responseHandler'; import { ResponseHandler } from '@utils/responseHandler';
import { activityService } from '@services/activity.service'; import { activityService } from '@services/activity.service';
import { gcsStorageService } from '@services/gcsStorage.service'; import { gcsStorageService } from '@services/gcsStorage.service';
import { emailNotificationService } from '@services/emailNotification.service';
import { notificationService } from '@services/notification.service';
import type { AuthenticatedRequest } from '../types/express'; import type { AuthenticatedRequest } from '../types/express';
import { getRequestMetadata } from '@utils/requestUtils'; import { getRequestMetadata } from '@utils/requestUtils';
import { getConfigNumber, getConfigValue } from '@services/configReader.service'; import { getConfigNumber, getConfigValue } from '@services/configReader.service';
@ -138,84 +133,16 @@ export class DocumentController {
} }
} }
// Check if storageUrl exceeds database column limit (500 chars) const doc = await Document.create({
// GCS signed URLs can be very long (500-1000+ chars)
const MAX_STORAGE_URL_LENGTH = 500;
let finalStorageUrl = storageUrl;
if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) {
logWithContext('warn', 'Storage URL exceeds database column limit, truncating', {
originalLength: storageUrl.length,
maxLength: MAX_STORAGE_URL_LENGTH,
urlPrefix: storageUrl.substring(0, 100),
});
// For signed URLs, we can't truncate as it will break the URL
// Instead, store null and generate signed URLs on-demand when needed
// The filePath is sufficient to generate a new signed URL later
finalStorageUrl = null as any;
logWithContext('info', 'Storing null storageUrl - will generate signed URL on-demand', {
filePath: gcsFilePath,
reason: 'Signed URL too long for database column',
});
}
// Truncate file names if they exceed database column limits (255 chars)
const MAX_FILE_NAME_LENGTH = 255;
const originalFileName = file.originalname;
let truncatedOriginalFileName = originalFileName;
if (originalFileName.length > MAX_FILE_NAME_LENGTH) {
// Preserve file extension when truncating
const ext = path.extname(originalFileName);
const nameWithoutExt = path.basename(originalFileName, ext);
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
if (maxNameLength > 0) {
truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
} else {
// If extension itself is too long, just use the extension
truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
}
logWithContext('warn', 'File name truncated to fit database column', {
originalLength: originalFileName.length,
truncatedLength: truncatedOriginalFileName.length,
originalName: originalFileName.substring(0, 100) + '...',
truncatedName: truncatedOriginalFileName,
});
}
// Generate fileName (basename of the generated file name in GCS)
const generatedFileName = path.basename(gcsFilePath);
let truncatedFileName = generatedFileName;
if (generatedFileName.length > MAX_FILE_NAME_LENGTH) {
const ext = path.extname(generatedFileName);
const nameWithoutExt = path.basename(generatedFileName, ext);
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
if (maxNameLength > 0) {
truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
} else {
truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
}
logWithContext('warn', 'Generated file name truncated', {
originalLength: generatedFileName.length,
truncatedLength: truncatedFileName.length,
});
}
// Prepare document data
const documentData = {
requestId, requestId,
uploadedBy: userId, uploadedBy: userId,
fileName: truncatedFileName, fileName: path.basename(file.filename || file.originalname),
originalFileName: truncatedOriginalFileName, originalFileName: file.originalname,
fileType: extension, fileType: extension,
fileExtension: extension, fileExtension: extension,
fileSize: file.size, fileSize: file.size,
filePath: gcsFilePath, // Store GCS path or local path filePath: gcsFilePath, // Store GCS path or local path
storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) storageUrl: storageUrl, // Store GCS URL or local URL
mimeType: file.mimetype, mimeType: file.mimetype,
checksum, checksum,
isGoogleDoc: false, isGoogleDoc: false,
@ -225,43 +152,7 @@ export class DocumentController {
parentDocumentId: null as any, parentDocumentId: null as any,
isDeleted: false, isDeleted: false,
downloadCount: 0, downloadCount: 0,
}; } as any);
logWithContext('info', 'Creating document record', {
requestId,
userId,
fileName: file.originalname,
filePath: gcsFilePath,
storageUrl: storageUrl,
documentData: JSON.stringify(documentData, null, 2),
});
let doc;
try {
doc = await Document.create(documentData as any);
logWithContext('info', 'Document record created successfully', {
documentId: doc.documentId,
requestId,
fileName: file.originalname,
});
} catch (createError) {
const createErrorMessage = createError instanceof Error ? createError.message : 'Unknown error';
const createErrorStack = createError instanceof Error ? createError.stack : undefined;
// Check if it's a Sequelize validation error
const sequelizeError = (createError as any)?.errors || (createError as any)?.parent;
logWithContext('error', 'Document.create() failed', {
error: createErrorMessage,
stack: createErrorStack,
sequelizeErrors: sequelizeError,
requestId,
userId,
fileName: file.originalname,
filePath: gcsFilePath,
storageUrl: storageUrl,
documentData: JSON.stringify(documentData, null, 2),
});
throw createError; // Re-throw to be caught by outer catch block
}
// Log document upload event // Log document upload event
logDocumentEvent('uploaded', doc.documentId, { logDocumentEvent('uploaded', doc.documentId, {
@ -296,205 +187,6 @@ export class DocumentController {
userAgent: requestMeta.userAgent userAgent: requestMeta.userAgent
}); });
// Send notifications for additional document added
try {
const initiatorId = (workflowRequest as any).initiatorId || (workflowRequest as any).initiator_id;
const isInitiator = userId === initiatorId;
// Get all participants (spectators)
const spectators = await Participant.findAll({
where: {
requestId,
participantType: 'SPECTATOR'
},
include: [{
model: User,
as: 'user',
attributes: ['userId', 'email', 'displayName']
}]
});
// Get current approver (pending or in-progress approval level)
const currentApprovalLevel = await ApprovalLevel.findOne({
where: {
requestId,
status: { [Op.in]: ['PENDING', 'IN_PROGRESS'] }
},
order: [['levelNumber', 'ASC']],
include: [{
model: User,
as: 'approver',
attributes: ['userId', 'email', 'displayName']
}]
});
logWithContext('info', 'Current approver lookup for document notification', {
requestId,
currentApprovalLevelFound: !!currentApprovalLevel,
approverUserId: currentApprovalLevel ? ((currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver)?.userId : null,
isInitiator
});
// Determine who to notify based on who uploaded
const recipientsToNotify: Array<{ userId: string; email: string; displayName: string }> = [];
if (isInitiator) {
// Initiator added → notify spectators and current approver
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Check if uploader is a spectator
const uploaderParticipant = await Participant.findOne({
where: {
requestId,
userId,
participantType: 'SPECTATOR'
}
});
if (uploaderParticipant) {
// Spectator added → notify initiator and current approver
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
if (currentApprovalLevel) {
const approverUser = (currentApprovalLevel as any).approver || (currentApprovalLevel as any).Approver;
if (approverUser && approverUser.userId !== userId) {
recipientsToNotify.push({
userId: approverUser.userId,
email: approverUser.email,
displayName: approverUser.displayName || approverUser.email
});
}
}
} else {
// Approver added → notify initiator and spectators
const initiator = await User.findByPk(initiatorId);
if (initiator) {
const initiatorData = initiator.toJSON();
if (initiatorData.userId !== userId) {
recipientsToNotify.push({
userId: initiatorData.userId,
email: initiatorData.email,
displayName: initiatorData.displayName || initiatorData.email
});
}
}
spectators.forEach((spectator: any) => {
const spectatorUser = spectator.user || spectator.User;
if (spectatorUser && spectatorUser.userId !== userId) {
recipientsToNotify.push({
userId: spectatorUser.userId,
email: spectatorUser.email,
displayName: spectatorUser.displayName || spectatorUser.email
});
}
});
}
}
// Send notifications (email, in-app, and web-push)
const requestData = {
requestNumber: requestNumber,
requestId: requestId,
title: (workflowRequest as any).title || 'Request'
};
// Prepare user IDs for in-app and web-push notifications
const recipientUserIds = recipientsToNotify.map(r => r.userId);
// Send in-app and web-push notifications
if (recipientUserIds.length > 0) {
try {
await notificationService.sendToUsers(
recipientUserIds,
{
title: 'Additional Document Added',
body: `${uploaderName} added "${file.originalname}" to ${requestNumber}`,
requestId,
requestNumber,
url: `/request/${requestNumber}`,
type: 'document_added',
priority: 'MEDIUM',
actionRequired: false,
metadata: {
documentName: file.originalname,
fileSize: file.size,
addedByName: uploaderName,
source: 'Documents Tab'
}
}
);
logWithContext('info', 'In-app and web-push notifications sent for additional document', {
requestId,
documentName: file.originalname,
recipientsCount: recipientUserIds.length
});
} catch (notifyError) {
logWithContext('error', 'Failed to send in-app/web-push notifications for additional document', {
requestId,
error: notifyError instanceof Error ? notifyError.message : 'Unknown error'
});
}
}
// Send email notifications
for (const recipient of recipientsToNotify) {
await emailNotificationService.sendAdditionalDocumentAdded(
requestData,
recipient,
{
documentName: file.originalname,
fileSize: file.size,
addedByName: uploaderName,
source: 'Documents Tab'
}
);
}
logWithContext('info', 'Additional document notifications sent', {
requestId,
documentName: file.originalname,
recipientsCount: recipientsToNotify.length,
isInitiator
});
} catch (notifyError) {
// Don't fail document upload if notifications fail
logWithContext('error', 'Failed to send additional document notifications', {
requestId,
error: notifyError instanceof Error ? notifyError.message : 'Unknown error'
});
}
ResponseHandler.success(res, doc, 'File uploaded', 201); ResponseHandler.success(res, doc, 'File uploaded', 201);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';

View File

@ -19,7 +19,6 @@ export class TemplateController {
} }
const { const {
// New fields
templateName, templateName,
templateCode, templateCode,
templateDescription, templateDescription,
@ -31,34 +30,20 @@ export class TemplateController {
userFieldMappings, userFieldMappings,
dynamicApproverConfig, dynamicApproverConfig,
isActive, isActive,
// Legacy fields (from frontend)
name,
description,
category,
approvers,
suggestedSLA
} = req.body; } = req.body;
// Map legacy to new if (!templateName) {
const finalTemplateName = templateName || name;
const finalTemplateDescription = templateDescription || description;
const finalTemplateCategory = templateCategory || category;
const finalApprovalLevelsConfig = approvalLevelsConfig || approvers;
const finalDefaultTatHours = defaultTatHours || suggestedSLA;
if (!finalTemplateName) {
return ResponseHandler.error(res, 'Template name is required', 400); return ResponseHandler.error(res, 'Template name is required', 400);
} }
const template = await this.templateService.createTemplate(userId, { const template = await this.templateService.createTemplate(userId, {
templateName: finalTemplateName, templateName,
templateCode, templateCode,
templateDescription: finalTemplateDescription, templateDescription,
templateCategory: finalTemplateCategory, templateCategory,
workflowType, workflowType,
approvalLevelsConfig: finalApprovalLevelsConfig, approvalLevelsConfig,
defaultTatHours: finalDefaultTatHours ? parseFloat(finalDefaultTatHours) : undefined, defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
formStepsConfig, formStepsConfig,
userFieldMappings, userFieldMappings,
dynamicApproverConfig, dynamicApproverConfig,
@ -164,21 +149,14 @@ export class TemplateController {
userFieldMappings, userFieldMappings,
dynamicApproverConfig, dynamicApproverConfig,
isActive, isActive,
// Legacy
name,
description,
category,
approvers,
suggestedSLA
} = req.body; } = req.body;
const template = await this.templateService.updateTemplate(templateId, userId, { const template = await this.templateService.updateTemplate(templateId, userId, {
templateName: templateName || name, templateName,
templateDescription: templateDescription || description, templateDescription,
templateCategory: templateCategory || category, templateCategory,
approvalLevelsConfig: approvalLevelsConfig || approvers, approvalLevelsConfig,
defaultTatHours: (defaultTatHours || suggestedSLA) ? parseFloat(defaultTatHours || suggestedSLA) : undefined, defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
formStepsConfig, formStepsConfig,
userFieldMappings, userFieldMappings,
dynamicApproverConfig, dynamicApproverConfig,

View File

@ -13,11 +13,9 @@ import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import { getRequestMetadata } from '@utils/requestUtils'; import { getRequestMetadata } from '@utils/requestUtils';
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service'; import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
import { DealerClaimService } from '@services/dealerClaim.service';
import logger from '@utils/logger'; import logger from '@utils/logger';
const workflowService = new WorkflowService(); const workflowService = new WorkflowService();
const dealerClaimService = new DealerClaimService();
export class WorkflowController { export class WorkflowController {
async createWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> { async createWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
@ -281,114 +279,27 @@ export class WorkflowController {
} }
} }
// Truncate file names if they exceed database column limits (255 chars) const doc = await Document.create({
const MAX_FILE_NAME_LENGTH = 255; requestId: workflow.requestId,
const originalFileName = file.originalname; uploadedBy: userId,
let truncatedOriginalFileName = originalFileName; fileName: path.basename(file.filename || file.originalname),
originalFileName: file.originalname,
if (originalFileName.length > MAX_FILE_NAME_LENGTH) { fileType: extension,
// Preserve file extension when truncating fileExtension: extension,
const ext = path.extname(originalFileName); fileSize: file.size,
const nameWithoutExt = path.basename(originalFileName, ext); filePath: gcsFilePath, // Store GCS path or local path
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; storageUrl: storageUrl, // Store GCS URL or local URL
mimeType: file.mimetype,
if (maxNameLength > 0) { checksum,
truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; isGoogleDoc: false,
} else { googleDocUrl: null as any,
// If extension itself is too long, just use the extension category: category || 'OTHER',
truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); version: 1,
} parentDocumentId: null as any,
isDeleted: false,
logger.warn('[Workflow] File name truncated to fit database column', { downloadCount: 0,
originalLength: originalFileName.length, } as any);
truncatedLength: truncatedOriginalFileName.length, docs.push(doc);
originalName: originalFileName.substring(0, 100) + '...',
truncatedName: truncatedOriginalFileName,
});
}
// Generate fileName (basename of the generated file name in GCS)
const generatedFileName = path.basename(gcsFilePath);
let truncatedFileName = generatedFileName;
if (generatedFileName.length > MAX_FILE_NAME_LENGTH) {
const ext = path.extname(generatedFileName);
const nameWithoutExt = path.basename(generatedFileName, ext);
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
if (maxNameLength > 0) {
truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
} else {
truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
}
logger.warn('[Workflow] Generated file name truncated', {
originalLength: generatedFileName.length,
truncatedLength: truncatedFileName.length,
});
}
// Check if storageUrl exceeds database column limit (500 chars)
const MAX_STORAGE_URL_LENGTH = 500;
let finalStorageUrl = storageUrl;
if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) {
logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', {
originalLength: storageUrl.length,
maxLength: MAX_STORAGE_URL_LENGTH,
urlPrefix: storageUrl.substring(0, 100),
filePath: gcsFilePath,
});
// For signed URLs, store null and generate on-demand later
finalStorageUrl = null as any;
}
logger.info('[Workflow] Creating document record', {
fileName: truncatedOriginalFileName,
filePath: gcsFilePath,
storageUrl: finalStorageUrl ? 'present' : 'null (too long)',
requestId: workflow.requestId
});
try {
const doc = await Document.create({
requestId: workflow.requestId,
uploadedBy: userId,
fileName: truncatedFileName,
originalFileName: truncatedOriginalFileName,
fileType: extension,
fileExtension: extension,
fileSize: file.size,
filePath: gcsFilePath, // Store GCS path or local path
storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long)
mimeType: file.mimetype,
checksum,
isGoogleDoc: false,
googleDocUrl: null as any,
category: category || 'OTHER',
version: 1,
parentDocumentId: null as any,
isDeleted: false,
downloadCount: 0,
} as any);
docs.push(doc);
logger.info('[Workflow] Document record created successfully', {
documentId: doc.documentId,
fileName: file.originalname,
});
} catch (docError) {
const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error';
const docErrorStack = docError instanceof Error ? docError.stack : undefined;
logger.error('[Workflow] Failed to create document record', {
error: docErrorMessage,
stack: docErrorStack,
fileName: file.originalname,
requestId: workflow.requestId,
filePath: gcsFilePath,
storageUrl: storageUrl,
});
// Re-throw to be caught by outer catch block
throw docError;
}
// Log document upload activity // Log document upload activity
const requestMeta = getRequestMetadata(req); const requestMeta = getRequestMetadata(req);
@ -409,13 +320,6 @@ export class WorkflowController {
ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201); ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
logger.error('[WorkflowController] createWorkflowMultipart failed', {
error: errorMessage,
stack: errorStack,
userId: req.user?.userId,
filesCount: (req as any).files?.length || 0,
});
ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage); ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage);
} }
} }
@ -476,7 +380,6 @@ export class WorkflowController {
search: req.query.search as string | undefined, search: req.query.search as string | undefined,
status: req.query.status as string | undefined, status: req.query.status as string | undefined,
priority: req.query.priority as string | undefined, priority: req.query.priority as string | undefined,
templateType: req.query.templateType as string | undefined,
department: req.query.department as string | undefined, department: req.query.department as string | undefined,
initiator: req.query.initiator as string | undefined, initiator: req.query.initiator as string | undefined,
approver: req.query.approver as string | undefined, approver: req.query.approver as string | undefined,
@ -538,7 +441,6 @@ export class WorkflowController {
const search = req.query.search as string | undefined; const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined; const status = req.query.status as string | undefined;
const priority = req.query.priority as string | undefined; const priority = req.query.priority as string | undefined;
const templateType = req.query.templateType as string | undefined;
const department = req.query.department as string | undefined; const department = req.query.department as string | undefined;
const initiator = req.query.initiator as string | undefined; const initiator = req.query.initiator as string | undefined;
const approver = req.query.approver as string | undefined; const approver = req.query.approver as string | undefined;
@ -548,7 +450,7 @@ export class WorkflowController {
const startDate = req.query.startDate as string | undefined; const startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined; const endDate = req.query.endDate as string | undefined;
const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate }; const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
const result = await workflowService.listParticipantRequests(userId, page, limit, filters); const result = await workflowService.listParticipantRequests(userId, page, limit, filters);
ResponseHandler.success(res, result, 'Participant requests fetched'); ResponseHandler.success(res, result, 'Participant requests fetched');
@ -571,14 +473,13 @@ export class WorkflowController {
const search = req.query.search as string | undefined; const search = req.query.search as string | undefined;
const status = req.query.status as string | undefined; const status = req.query.status as string | undefined;
const priority = req.query.priority as string | undefined; const priority = req.query.priority as string | undefined;
const templateType = req.query.templateType as string | undefined;
const department = req.query.department as string | undefined; const department = req.query.department as string | undefined;
const slaCompliance = req.query.slaCompliance as string | undefined; const slaCompliance = req.query.slaCompliance as string | undefined;
const dateRange = req.query.dateRange as string | undefined; const dateRange = req.query.dateRange as string | undefined;
const startDate = req.query.startDate as string | undefined; const startDate = req.query.startDate as string | undefined;
const endDate = req.query.endDate as string | undefined; const endDate = req.query.endDate as string | undefined;
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate }; const filters = { search, status, priority, department, slaCompliance, dateRange, startDate, endDate };
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters); const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
ResponseHandler.success(res, result, 'My initiated requests fetched'); ResponseHandler.success(res, result, 'My initiated requests fetched');
@ -598,8 +499,7 @@ export class WorkflowController {
const filters = { const filters = {
search: req.query.search as string | undefined, search: req.query.search as string | undefined,
status: req.query.status as string | undefined, status: req.query.status as string | undefined,
priority: req.query.priority as string | undefined, priority: req.query.priority as string | undefined
templateType: req.query.templateType as string | undefined
}; };
// Extract sorting parameters // Extract sorting parameters
@ -624,8 +524,7 @@ export class WorkflowController {
const filters = { const filters = {
search: req.query.search as string | undefined, search: req.query.search as string | undefined,
status: req.query.status as string | undefined, status: req.query.status as string | undefined,
priority: req.query.priority as string | undefined, priority: req.query.priority as string | undefined
templateType: req.query.templateType as string | undefined
}; };
// Extract sorting parameters // Extract sorting parameters
@ -688,27 +587,10 @@ export class WorkflowController {
} }
// Update workflow // Update workflow
let workflow; const workflow = await workflowService.updateWorkflow(id, updateData);
try { if (!workflow) {
workflow = await workflowService.updateWorkflow(id, updateData); ResponseHandler.notFound(res, 'Workflow not found');
if (!workflow) { return;
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
logger.info('[WorkflowController] Workflow updated successfully', {
requestId: id,
workflowId: (workflow as any).requestId,
});
} catch (updateError) {
const updateErrorMessage = updateError instanceof Error ? updateError.message : 'Unknown error';
const updateErrorStack = updateError instanceof Error ? updateError.stack : undefined;
logger.error('[WorkflowController] updateWorkflow failed', {
error: updateErrorMessage,
stack: updateErrorStack,
requestId: id,
updateData: JSON.stringify(updateData, null, 2),
});
throw updateError; // Re-throw to be caught by outer catch block
} }
// Attach new files as documents // Attach new files as documents
@ -745,129 +627,40 @@ export class WorkflowController {
} }
} }
// Truncate file names if they exceed database column limits (255 chars)
const MAX_FILE_NAME_LENGTH = 255;
const originalFileName = file.originalname;
let truncatedOriginalFileName = originalFileName;
if (originalFileName.length > MAX_FILE_NAME_LENGTH) {
// Preserve file extension when truncating
const ext = path.extname(originalFileName);
const nameWithoutExt = path.basename(originalFileName, ext);
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
if (maxNameLength > 0) {
truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
} else {
// If extension itself is too long, just use the extension
truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
}
logger.warn('[Workflow] File name truncated to fit database column', {
originalLength: originalFileName.length,
truncatedLength: truncatedOriginalFileName.length,
originalName: originalFileName.substring(0, 100) + '...',
truncatedName: truncatedOriginalFileName,
});
}
// Generate fileName (basename of the generated file name in GCS)
const generatedFileName = path.basename(gcsFilePath);
let truncatedFileName = generatedFileName;
if (generatedFileName.length > MAX_FILE_NAME_LENGTH) {
const ext = path.extname(generatedFileName);
const nameWithoutExt = path.basename(generatedFileName, ext);
const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length;
if (maxNameLength > 0) {
truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext;
} else {
truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH);
}
logger.warn('[Workflow] Generated file name truncated', {
originalLength: generatedFileName.length,
truncatedLength: truncatedFileName.length,
});
}
// Check if storageUrl exceeds database column limit (500 chars)
const MAX_STORAGE_URL_LENGTH = 500;
let finalStorageUrl = storageUrl;
if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) {
logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', {
originalLength: storageUrl.length,
maxLength: MAX_STORAGE_URL_LENGTH,
urlPrefix: storageUrl.substring(0, 100),
filePath: gcsFilePath,
});
// For signed URLs, store null and generate on-demand later
finalStorageUrl = null as any;
}
logger.info('[Workflow] Creating document record', { logger.info('[Workflow] Creating document record', {
fileName: truncatedOriginalFileName, fileName: file.originalname,
filePath: gcsFilePath, filePath: gcsFilePath,
storageUrl: finalStorageUrl ? 'present' : 'null (too long)', storageUrl: storageUrl,
requestId: actualRequestId requestId: actualRequestId
}); });
try { const doc = await Document.create({
const doc = await Document.create({ requestId: actualRequestId,
requestId: actualRequestId, uploadedBy: userId,
uploadedBy: userId, fileName: path.basename(file.filename || file.originalname),
fileName: truncatedFileName, originalFileName: file.originalname,
originalFileName: truncatedOriginalFileName, fileType: extension,
fileType: extension, fileExtension: extension,
fileExtension: extension, fileSize: file.size,
fileSize: file.size, filePath: gcsFilePath, // Store GCS path or local path
filePath: gcsFilePath, // Store GCS path or local path storageUrl: storageUrl, // Store GCS URL or local URL
storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) mimeType: file.mimetype,
mimeType: file.mimetype, checksum,
checksum, isGoogleDoc: false,
isGoogleDoc: false, googleDocUrl: null as any,
googleDocUrl: null as any, category: category || 'OTHER',
category: category || 'OTHER', version: 1,
version: 1, parentDocumentId: null as any,
parentDocumentId: null as any, isDeleted: false,
isDeleted: false, downloadCount: 0,
downloadCount: 0, } as any);
} as any); docs.push(doc);
docs.push(doc);
logger.info('[Workflow] Document record created successfully', {
documentId: doc.documentId,
fileName: file.originalname,
});
} catch (docError) {
const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error';
const docErrorStack = docError instanceof Error ? docError.stack : undefined;
logger.error('[Workflow] Failed to create document record', {
error: docErrorMessage,
stack: docErrorStack,
fileName: file.originalname,
requestId: actualRequestId,
filePath: gcsFilePath,
storageUrl: storageUrl,
});
// Continue with other files, but log the error
// Don't throw here - let the workflow update complete
}
} }
} }
ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200); ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const errorStack = error instanceof Error ? error.stack : undefined;
logger.error('[WorkflowController] updateWorkflowMultipart failed', {
error: errorMessage,
stack: errorStack,
requestId: req.params.id,
userId: req.user?.userId,
hasFiles: !!(req as any).files && (req as any).files.length > 0,
fileCount: (req as any).files ? (req as any).files.length : 0,
});
ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage); ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage);
} }
} }
@ -888,54 +681,4 @@ export class WorkflowController {
ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage); ResponseHandler.error(res, 'Failed to submit workflow', 400, errorMessage);
} }
} }
async handleInitiatorAction(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
const { action, ...data } = req.body;
const userId = req.user?.userId;
if (!userId) {
ResponseHandler.unauthorized(res, 'User ID missing from request');
return;
}
await dealerClaimService.handleInitiatorAction(id, userId, action as any, data);
ResponseHandler.success(res, null, `Action ${action} performed successfully`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[WorkflowController] handleInitiatorAction failed', {
error: errorMessage,
requestId: req.params.id,
userId: req.user?.userId,
action: req.body.action
});
ResponseHandler.error(res, 'Failed to perform initiator action', 400, errorMessage);
}
}
async getHistory(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params;
// Resolve requestId UUID from identifier (could be requestNumber or UUID)
const workflowService = new WorkflowService();
const wf = await (workflowService as any).findWorkflowByIdentifier(id);
if (!wf) {
ResponseHandler.notFound(res, 'Workflow not found');
return;
}
const requestId = wf.getDataValue('requestId');
const history = await dealerClaimService.getHistory(requestId);
ResponseHandler.success(res, history, 'Revision history fetched successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.error('[WorkflowController] getHistory failed', {
error: errorMessage,
requestId: req.params.id
});
ResponseHandler.error(res, 'Failed to fetch revision history', 400, errorMessage);
}
}
} }

View File

@ -1,130 +0,0 @@
import { Request, Response } from 'express';
import { WorkflowTemplate } from '../models';
import logger from '../utils/logger';
export const createTemplate = async (req: Request, res: Response) => {
try {
const { name, description, category, priority, estimatedTime, approvers, suggestedSLA } = req.body;
const userId = (req as any).user?.userId;
const template = await WorkflowTemplate.create({
templateName: name,
templateDescription: description,
templateCategory: category,
approvalLevelsConfig: approvers,
defaultTatHours: suggestedSLA,
createdBy: userId,
isActive: true,
isSystemTemplate: false,
usageCount: 0
});
res.status(201).json({
success: true,
message: 'Workflow template created successfully',
data: template
});
} catch (error) {
logger.error('Error creating workflow template:', error);
res.status(500).json({
success: false,
message: 'Failed to create workflow template',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
export const getTemplates = async (req: Request, res: Response) => {
try {
const templates = await WorkflowTemplate.findAll({
where: { isActive: true },
order: [['createdAt', 'DESC']]
});
res.status(200).json({
success: true,
data: templates
});
} catch (error) {
logger.error('Error fetching workflow templates:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch workflow templates',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
export const updateTemplate = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { name, description, category, approvers, suggestedSLA, isActive } = req.body;
const updates: any = {};
if (name) updates.templateName = name;
if (description) updates.templateDescription = description;
if (category) updates.templateCategory = category;
if (approvers) updates.approvalLevelsConfig = approvers;
if (suggestedSLA) updates.defaultTatHours = suggestedSLA;
if (isActive !== undefined) updates.isActive = isActive;
const template = await WorkflowTemplate.findByPk(id);
if (!template) {
return res.status(404).json({
success: false,
message: 'Workflow template not found'
});
}
await template.update(updates);
return res.status(200).json({
success: true,
message: 'Workflow template updated successfully',
data: template
});
} catch (error) {
logger.error('Error updating workflow template:', error);
return res.status(500).json({
success: false,
message: 'Failed to update workflow template',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
export const deleteTemplate = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const template = await WorkflowTemplate.findByPk(id);
if (!template) {
return res.status(404).json({
success: false,
message: 'Workflow template not found'
});
}
// Hard delete or Soft delete based on preference.
// Since we have isActive flag, let's use that (Soft Delete) or just destroy if it's unused.
// For now, let's do a hard delete to match the expectation of "Delete" in the UI
// unless there are FK constraints (which sequelize handles).
// Actually, safer to Soft Delete by setting isActive = false if we want history,
// but user asked for Delete. Let's do destroy.
await template.destroy();
return res.status(200).json({
success: true,
message: 'Workflow template deleted successfully'
});
} catch (error) {
logger.error('Error deleting workflow template:', error);
return res.status(500).json({
success: false,
message: 'Failed to delete workflow template',
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};

View File

@ -1,297 +0,0 @@
# Additional Approver Handling in Dealer Claim Email Templates
## Overview
This document explains how the dealer claim email notification system handles additional approvers that are added dynamically between fixed workflow steps.
---
## How Additional Approvers Work
### 1. **Additional Approver Detection**
Additional approvers are identified by their `levelName` containing "Additional Approver". The system uses this to:
- Exclude them from dealer-specific templates
- Use appropriate templates for their notifications
- Track them in the approval chain
### 2. **Step Number Shifting**
When additional approvers are added:
- **Before Step 1**: Dealer Proposal Submission remains Step 1
- **Between Step 1 and Step 2**: Additional approver becomes Step 2, Requestor Evaluation shifts to Step 3
- **Between Step 2 and Step 3**: Additional approver inserted, subsequent steps shift
- And so on...
The system handles this by:
- Using `levelName` to identify steps (not just `levelNumber`)
- Finding the next PENDING level dynamically (not just sequential)
- Detecting additional approvers by checking `levelName`
---
## Email Notification Scenarios
### Scenario 1: Dealer Submits Proposal (Step 1)
#### **Initiator Notification**
- **When**: Dealer proposal is approved (Step 1 → Step 2)
- **Template**: `dealerProposalSubmitted.template.ts`
- **Notification Type**: `proposal_submitted`
- **Handles Additional Approvers**: ✅ Yes
- If next approver is additional: Shows "Additional Approver" as next approver name
- If next approver is Step 2: Shows "Requestor Evaluation" approver name
- Uses `nextLevel` which is found dynamically (handles step shifts)
#### **Next Approver Notification**
- **When**: Next approver is assigned (could be Step 2 or Additional Approver)
- **Template**:
- If Additional Approver: `approvalRequest.template.ts` or `multiApproverRequest.template.ts`
- If Step 2 (Requestor): `approvalRequest.template.ts` or `multiApproverRequest.template.ts`
- **Notification Type**: `assignment`
- **Handles Additional Approvers**: ✅ Yes
- Additional approvers get standard approval request template
- Not dealer-specific (correct behavior)
---
### Scenario 2: Additional Approver Added Between Step 1 and Step 2
**Workflow Structure:**
```
Step 1: Dealer Proposal Submission (Dealer)
Step 2: Additional Approver (New Approver) ← Added dynamically
Step 3: Requestor Evaluation (Initiator) ← Shifted from Step 2
Step 4: Department Lead Approval
...
```
**Email Flow:**
1. **Dealer submits proposal** → Step 1 approved
2. **Initiator gets email**:
- Template: `dealerProposalSubmitted.template.ts`
- Shows: "Next approver: Additional Approver" (if metadata includes `nextApproverIsAdditional`)
3. **Additional Approver gets email**:
- Template: `approvalRequest.template.ts` (standard approval request)
- Type: `assignment`
- Shows approval chain if multiple approvers exist
4. **Additional Approver approves** → Step 2 approved
5. **Initiator gets email**:
- Template: `approvalConfirmation.template.ts` (standard approval confirmation)
- Type: `approval`
6. **Requestor (Step 3) gets email**:
- Template: `approvalRequest.template.ts` (standard approval request)
- Type: `assignment`
---
### Scenario 3: Dealer Submits Completion Documents (Step 4)
#### **Initiator Notification**
- **When**: Dealer completion documents are approved (Step 4 → Step 5)
- **Template**: `completionDocumentsSubmitted.template.ts`
- **Notification Type**: `completion_submitted`
- **Handles Additional Approvers**: ✅ Yes
- If next approver is additional: Shows "Additional Approver" as next approver name
- If next approver is Step 5: Shows "Requestor Claim Approval" approver name
---
## Key Implementation Details
### 1. **Dynamic Next Level Finding**
```typescript
// In dealerClaimApproval.service.ts
// First try sequential approach
let nextLevel = await ApprovalLevel.findOne({
where: {
requestId: level.requestId,
levelNumber: currentLevelNumber + 1
}
});
// If sequential level doesn't exist, search for next PENDING level
// This handles cases where additional approvers are added dynamically
if (!nextLevel) {
nextLevel = await ApprovalLevel.findOne({
where: {
requestId: level.requestId,
levelNumber: { [Op.gt]: currentLevelNumber },
status: ApprovalStatus.PENDING
},
order: [['levelNumber', 'ASC']]
});
}
```
### 2. **Additional Approver Detection**
```typescript
// Check if next approver is an additional approver
const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : '';
const isNextAdditionalApprover = nextLevelName.includes('additional approver');
```
### 3. **Template Selection Logic**
#### **Assignment Notifications** (`notification.service.ts`)
```typescript
case 'assignment':
// Check if this is a dealer proposal step
const levelName = currentLevel ? (currentLevel.levelName || '').toLowerCase() : '';
const isAdditionalApprover = levelName.includes('additional approver');
const isDealerProposalStep = currentLevel && !isAdditionalApprover && (
(currentLevel.levelName && (
currentLevel.levelName.toLowerCase().includes('dealer') &&
currentLevel.levelName.toLowerCase().includes('proposal')
)) ||
(currentLevel.levelNumber === 1 && requestData.workflowType === 'CLAIM_MANAGEMENT')
);
if (isDealerProposalStep) {
// Use dealer-specific template
await emailNotificationService.sendDealerProposalRequired(...);
} else {
// Use standard approval request template (works for additional approvers)
await emailNotificationService.sendApprovalRequest(...);
}
```
### 4. **Proposal Submitted Notification**
```typescript
// In dealerClaimApproval.service.ts
// When dealer proposal is approved
if (isDealerProposalApproval && (wf as any).initiatorId) {
// Get next approver (could be Step 2 or Additional Approver)
const nextApproverData = nextLevel ? await User.findByPk((nextLevel as any).approverId) : null;
// Check if next approver is additional
const nextLevelName = nextLevel ? ((nextLevel as any).levelName || '').toLowerCase() : '';
const isNextAdditionalApprover = nextLevelName.includes('additional approver');
// Send proposal_submitted notification (not approval notification)
await notificationService.sendToUsers([(wf as any).initiatorId], {
type: 'proposal_submitted', // NOT 'approval'
metadata: {
proposalData: {
nextApproverIsAdditional: isNextAdditionalApprover
},
nextApproverId: nextApproverData ? nextApproverData.userId : undefined
}
});
}
```
---
## Email Template Behavior
### ✅ **Templates That Handle Additional Approvers**
1. **Dealer Proposal Submitted** (`dealerProposalSubmitted.template.ts`)
- Shows next approver name (or "Additional Approver" if applicable)
- Works correctly when next approver is additional
2. **Completion Documents Submitted** (`completionDocumentsSubmitted.template.ts`)
- Shows next approver name (or "Additional Approver" if applicable)
- Works correctly when next approver is additional
3. **Approval Request** (`approvalRequest.template.ts` / `multiApproverRequest.template.ts`)
- Used for additional approvers
- Shows approval chain if multiple approvers exist
- Works correctly for all approvers (fixed and additional)
4. **Approval Confirmation** (`approvalConfirmation.template.ts`)
- Used when additional approvers approve
- Shows next approver in chain
- Works correctly
### ❌ **Templates That Should NOT Be Used for Additional Approvers**
1. **Dealer Proposal Required** (`dealerProposalRequired.template.ts`)
- Only for dealer (Step 1)
- Additional approvers excluded via `!isAdditionalApprover` check
---
## Testing Scenarios
### Test Case 1: No Additional Approvers
- **Step 1** (Dealer) → Gets `dealerProposalRequired` template ✅
- **Step 1 approved** → Initiator gets `dealerProposalSubmitted` template ✅
- **Step 2** (Requestor) → Gets `approvalRequest` template ✅
### Test Case 2: Additional Approver Between Step 1 and Step 2
- **Step 1** (Dealer) → Gets `dealerProposalRequired` template ✅
- **Step 1 approved** → Initiator gets `dealerProposalSubmitted` template with "Additional Approver" as next ✅
- **Step 2** (Additional Approver) → Gets `approvalRequest` template ✅
- **Step 2 approved** → Initiator gets `approvalConfirmation` template ✅
- **Step 3** (Requestor) → Gets `approvalRequest` template ✅
### Test Case 3: Multiple Additional Approvers
- **Step 1** (Dealer) → Gets `dealerProposalRequired` template ✅
- **Step 1 approved** → Initiator gets `dealerProposalSubmitted` template ✅
- **Step 2** (Additional Approver 1) → Gets `multiApproverRequest` template (if multiple approvers) ✅
- **Step 2 approved** → Next approver notified ✅
- **Step 3** (Additional Approver 2) → Gets `multiApproverRequest` template ✅
- And so on...
---
## Summary
### ✅ **What Works Correctly**
1. **Dealer Assignment**: Uses dealer-specific template (not multi-level approval)
2. **Proposal Submitted**: Initiator gets `proposal_submitted` template (not multi-level approval)
3. **Additional Approvers**: Get standard approval request templates
4. **Next Approver Detection**: Dynamically finds next approver (handles step shifts)
5. **Template Selection**: Correctly identifies dealer steps vs additional approvers
### 🔧 **Key Logic**
- **Dealer Proposal Step Detection**: Checks `levelName` contains "dealer" and "proposal" OR `levelNumber === 1` AND `workflowType === 'CLAIM_MANAGEMENT'`
- **Additional Approver Detection**: Checks `levelName` contains "additional approver"
- **Next Level Finding**: Uses dynamic search for next PENDING level (not just sequential)
- **Template Selection**: Excludes additional approvers from dealer-specific templates
### 📧 **Email Flow**
```
Dealer Submits Proposal
Step 1 Approved (System)
Initiator: proposal_submitted email ✅ (NOT multi-level approval)
Next Approver: assignment email ✅ (Standard approval request)
If Next is Additional Approver:
- Gets standard approval request template ✅
- Shows in approval chain ✅
- When approved, next approver gets assignment ✅
```
---
## Files Modified
1. **`dealerClaimApproval.service.ts`**
- Added detection for additional approvers
- Changed notification type from `approval` to `proposal_submitted` for dealer proposal
- Added `nextApproverIsAdditional` metadata
2. **`notification.service.ts`**
- Added check to exclude additional approvers from dealer-specific templates
- Improved dealer proposal step detection
3. **`emailNotification.service.ts`**
- Updated to handle `nextApproverIsAdditional` flag
- Shows "Additional Approver" when next approver is additional
---
## Conclusion
The system now correctly handles additional approvers:
- ✅ Initiator gets `proposal_submitted` template (not multi-level approval)
- ✅ Additional approvers get standard approval request templates
- ✅ Next approver is correctly identified even when steps shift
- ✅ All templates work seamlessly with dynamic approval chains

View File

@ -1,393 +0,0 @@
# Dealer Claim Email Templates - Planning Document
## Overview
This document outlines all email templates required for the Dealer Claim Management workflow, including support for additional approvers.
---
## Workflow Steps & Email Templates
### 1. **Request Created** ✅ (Already Exists)
- **When**: Claim request is created by initiator
- **Recipients**: Initiator
- **Template**: `requestCreated.template.ts`
- **Status**: ✅ Implemented
- **Notes**: Generic template works for dealer claims
---
### 2. **Dealer Assignment - Proposal Required** ✅ (Uses Existing)
- **When**: Step 1 - Dealer is assigned to submit proposal
- **Recipients**: Dealer
- **Template**: `approvalRequest.template.ts` (single approver)
- **Status**: ✅ Uses existing template
- **Notification Type**: `assignment`
- **Notes**:
- Sent when request is created
- Uses existing approval request template
- May need dealer-specific customization
---
### 3. **Proposal Submitted** 🆕 (NEW - Recommended)
- **When**: Step 1 - Dealer submits proposal
- **Recipients**:
- Initiator (Requestor)
- Next Approver (Step 2 - Requestor Evaluation)
- **Template**: `dealerProposalSubmitted.template.ts` (NEW)
- **Status**: ❌ Not Implemented
- **Notification Type**: `proposal_submitted`
- **Data Needed**:
- Request details (number, title, activity name)
- Dealer information
- Proposal details (total budget, expected completion date)
- Cost breakdown summary
- Dealer comments
- **Notes**:
- Confirms to initiator that proposal was received
- Notifies next approver (initiator) to review
---
### 4. **Proposal Approved** ✅ (Uses Existing)
- **When**: Step 2 - Requestor approves proposal
- **Recipients**:
- Initiator (confirmation)
- Next Approver (Step 3 - Department Lead)
- **Template**: `approvalConfirmation.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval`
- **Notes**: Generic approval confirmation works
---
### 5. **Proposal Rejected** ✅ (Uses Existing)
- **When**: Step 2 - Requestor rejects proposal
- **Recipients**:
- Initiator
- Dealer
- All participants
- **Template**: `rejectionNotification.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `rejection`
- **Notes**: Generic rejection notification works
---
### 6. **Department Lead Approval** ✅ (Uses Existing)
- **When**: Step 3 - Department Lead approves and organizes IO
- **Recipients**:
- Initiator (confirmation)
- Next approver (if any additional approvers before Activity Creation)
- **Template**: `approvalConfirmation.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval`
- **Notes**:
- May want IO-specific details in email
- IO details are shown in workflow tab
---
### 7. **Activity Created** 🆕 (NEW - Recommended)
- **When**: After Step 3 approval - Activity is created
- **Recipients**:
- Dealer
- Initiator (Requestor)
- Department Lead
- **Template**: `activityCreated.template.ts` (NEW)
- **Status**: ❌ Not Implemented (currently uses generic notification)
- **Notification Type**: `activity_created`
- **Data Needed**:
- Activity name and type
- Request number
- Activity date
- Location
- IO number (if available)
- Next steps information
- **Notes**:
- Currently sends generic notification (line 2141-2151 in dealerClaim.service.ts)
- Should be a dedicated template with activity-specific information
---
### 8. **Completion Documents Submitted** 🆕 (NEW - Recommended)
- **When**: Step 4 - Dealer submits completion documents
- **Recipients**:
- Initiator (Requestor)
- Next Approver (Step 5 - Requestor Claim Approval)
- **Template**: `completionDocumentsSubmitted.template.ts` (NEW)
- **Status**: ❌ Not Implemented
- **Notification Type**: `completion_submitted`
- **Data Needed**:
- Request details
- Activity completion date
- Number of participants
- Total closed expenses
- Expense breakdown summary
- Documents submitted count
- **Notes**:
- Confirms to initiator that completion docs were received
- Notifies next approver to review completion
---
### 9. **Requestor Claim Approval** ✅ (Uses Existing)
- **When**: Step 5 - Requestor approves claim
- **Recipients**:
- Initiator (confirmation)
- Next step (DMS push)
- **Template**: `approvalConfirmation.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval`
- **Notes**: Generic approval confirmation works
---
### 10. **E-Invoice Generated** 🆕 (NEW - Recommended)
- **When**: Step 6 - E-Invoice is generated via DMS
- **Recipients**:
- Initiator
- Dealer
- Finance team (if applicable)
- **Template**: `einvoiceGenerated.template.ts` (NEW)
- **Status**: ❌ Not Implemented
- **Notification Type**: `einvoice_generated`
- **Data Needed**:
- E-Invoice number
- Invoice date
- DMS number
- Invoice amount
- Request details
- Download link (if available)
- **Notes**:
- Currently logged as activity only (line 1856-1863 in dealerClaim.service.ts)
- Should notify relevant parties when invoice is ready
---
### 11. **Credit Note Sent to Dealer** 🆕 (NEW - Recommended)
- **When**: Step 8 - Credit note is sent to dealer
- **Recipients**:
- Dealer (primary)
- Initiator (for record)
- Finance team
- **Template**: `creditNoteSent.template.ts` (NEW)
- **Status**: ❌ Not Implemented (TODO comment at line 2037-2044)
- **Notification Type**: `credit_note_sent`
- **Data Needed**:
- Credit note number
- Credit note date
- Credit note amount
- Request number
- Activity name
- Dealer information
- Reason for credit note
- Download link (if available)
- **Notes**:
- Currently has TODO comment for email implementation
- Critical for dealer notification
---
### 12. **Additional Approver Assignment** ✅ (Uses Existing)
- **When**: Additional approver is added between any steps
- **Recipients**: Additional Approver
- **Template**: `approvalRequest.template.ts` or `multiApproverRequest.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `assignment`
- **Notes**:
- Can use existing approval request templates
- Should show approval chain if multiple approvers
---
### 13. **Additional Approver Approval** ✅ (Uses Existing)
- **When**: Additional approver approves/rejects
- **Recipients**:
- Initiator
- Next approver
- **Template**: `approvalConfirmation.template.ts` or `rejectionNotification.template.ts`
- **Status**: ✅ Uses existing template
- **Notification Type**: `approval` or `rejection`
- **Notes**: Generic templates work for additional approvers
---
## Summary
### ✅ Already Implemented (Using Existing Templates)
1. Request Created
2. Dealer Assignment (Proposal Required)
3. Proposal Approved
4. Proposal Rejected
5. Department Lead Approval
6. Requestor Claim Approval
7. Additional Approver Assignment/Approval
### 🆕 New Templates Needed
1. **Proposal Submitted** (`dealerProposalSubmitted.template.ts`)
- Priority: Medium
- When: Dealer submits proposal (Step 1)
2. **Activity Created** (`activityCreated.template.ts`)
- Priority: High
- When: Activity is created after Step 3 approval
- Currently uses generic notification
3. **Completion Documents Submitted** (`completionDocumentsSubmitted.template.ts`)
- Priority: Medium
- When: Dealer submits completion docs (Step 4)
4. **E-Invoice Generated** (`einvoiceGenerated.template.ts`)
- Priority: High
- When: E-Invoice is generated via DMS (Step 6)
- Currently only logged as activity
5. **Credit Note Sent** (`creditNoteSent.template.ts`)
- Priority: High
- When: Credit note is sent to dealer (Step 8)
- Currently has TODO comment
---
## Implementation Priority
### High Priority (Critical for Workflow)
1. **Activity Created** - Currently using generic notification, should be branded
2. **E-Invoice Generated** - Important for financial tracking
3. **Credit Note Sent** - Critical for dealer notification (currently TODO)
### Medium Priority (Nice to Have)
4. **Proposal Submitted** - Better UX, but existing approval request works
5. **Completion Documents Submitted** - Better UX, but existing approval request works
---
## Template Design Considerations
### 1. Support for Additional Approvers
- All templates should handle dynamic approval chains
- Show approval chain when multiple approvers exist
- Use `multiApproverRequest.template.ts` pattern for multi-level scenarios
### 2. Dealer-Specific Information
- Include dealer name, code, email prominently
- Show activity name and type
- Include dealer-specific fields (dealer comments, etc.)
### 3. Financial Information
- Show budget/amount information clearly
- Include currency formatting (INR)
- Show expense breakdowns where relevant
### 4. Document Links
- Include links to view/download documents
- Link to request detail page
- Include document counts where relevant
### 5. Next Steps
- Clearly indicate what happens next
- Show who needs to take action
- Include deadlines/TAT information
---
## Integration Points
### Notification Service Integration
All new templates need to be integrated in:
- `Re_Backend/src/services/notification.service.ts`
- Add to `emailTypeMap`
- Add case in `triggerEmailByType` switch statement
### Email Notification Service Integration
All new templates need methods in:
- `Re_Backend/src/services/emailNotification.service.ts`
- Add `sendXXX` methods for each template
- Import template functions
- Handle data preparation
### Type Definitions
Add interfaces in:
- `Re_Backend/src/emailtemplates/types.ts`
- Define data interfaces for each template
### Email Preferences
Add notification types in:
- `Re_Backend/src/emailtemplates/emailPreferences.helper.ts`
- Add to `EmailNotificationType` enum
---
## Example Template Structure
Each new template should follow the pattern:
```typescript
// types.ts
export interface DealerProposalSubmittedData extends BaseEmailData {
dealerName: string;
activityName: string;
activityType: string;
proposalBudget: number;
expectedCompletionDate: string;
dealerComments?: string;
costBreakupSummary?: string; // Summary of cost items
// ... other fields
}
// dealerProposalSubmitted.template.ts
export function getDealerProposalSubmittedEmail(data: DealerProposalSubmittedData): string {
// HTML template with Royal Enfield branding
// Responsive design
// Rich text support for descriptions
// Table support for cost breakdown
}
// emailNotification.service.ts
async sendDealerProposalSubmitted(
requestData: any,
dealerData: any,
initiatorData: any,
proposalData: any
): Promise<void> {
// Prepare data
// Check preferences
// Send email
}
// notification.service.ts
case 'proposal_submitted':
await emailNotificationService.sendDealerProposalSubmitted(...);
break;
```
---
## Testing Checklist
For each new template:
- [ ] Template renders correctly
- [ ] All dynamic fields populate correctly
- [ ] Mobile responsive
- [ ] Tables display correctly (if applicable)
- [ ] Links work correctly
- [ ] Email preferences respected
- [ ] Works with additional approvers
- [ ] Handles missing optional data gracefully
- [ ] Branding consistent with other templates
---
## Notes
1. **Additional Approvers**: All templates should work seamlessly when additional approvers are added between fixed steps. The approval chain should be shown when relevant.
2. **System Steps**: Activity Creation, E-Invoice Generation, and Credit Note Confirmation are now activity logs only (not approval steps), but they still need email notifications.
3. **Dealer vs Internal Users**: Dealer may not be in the system initially - templates should handle this gracefully.
4. **Financial Data**: All financial amounts should be formatted as INR currency with proper decimal places.
5. **Document Links**: Include links to view/download documents where applicable, especially for proposals and completion documents.

View File

@ -1,358 +0,0 @@
# Dealer Claim Email Templates - Implementation Summary
## ✅ All 5 Templates Created and Integrated
All 5 new email templates for the dealer claim workflow have been successfully created and integrated into the notification system.
---
## 📧 Created Templates
### 1. **Dealer Proposal Submitted**
- **File**: `dealerProposalSubmitted.template.ts`
- **Notification Type**: `proposal_submitted`
- **Email Type**: `DEALER_PROPOSAL_SUBMITTED`
- **When**: Step 1 - Dealer submits proposal
- **Recipients**: Initiator and next approver
- **Features**:
- Shows proposal budget, expected completion date
- Cost breakdown table (if available)
- Dealer comments
- Next approver information
### 2. **Activity Created**
- **File**: `activityCreated.template.ts`
- **Notification Type**: `activity_created`
- **Email Type**: `ACTIVITY_CREATED`
- **When**: After Step 3 approval - Activity is created
- **Recipients**: Dealer, Initiator, Department Lead
- **Features**:
- Activity name, type, date, location
- Dealer information
- IO number (if available)
- Next steps information
### 3. **Completion Documents Submitted**
- **File**: `completionDocumentsSubmitted.template.ts`
- **Notification Type**: `completion_submitted`
- **Email Type**: `COMPLETION_DOCUMENTS_SUBMITTED`
- **When**: Step 4 - Dealer submits completion documents
- **Recipients**: Initiator and next approver
- **Features**:
- Completion date, participants count
- Total expenses with breakdown table
- Documents count
- Next approver information
### 4. **E-Invoice Generated**
- **File**: `einvoiceGenerated.template.ts`
- **Notification Type**: `einvoice_generated`
- **Email Type**: `EINVOICE_GENERATED`
- **When**: Step 6 - E-Invoice is generated via DMS
- **Recipients**: Initiator, Dealer, Finance team
- **Features**:
- Invoice number, date, DMS number
- Invoice amount
- Download link (if available)
- IO number and dealer information
### 5. **Credit Note Sent**
- **File**: `creditNoteSent.template.ts`
- **Notification Type**: `credit_note_sent`
- **Email Type**: `CREDIT_NOTE_SENT`
- **When**: Step 8 - Credit note is sent to dealer
- **Recipients**: Dealer (primary), Initiator, Finance team
- **Features**:
- Credit note number, date, amount
- Related invoice number
- Reason for credit note
- Download link (if available)
- Completion message
---
## 🔧 Integration Points
### ✅ Type Definitions Added
- `DealerProposalSubmittedData` interface
- `ActivityCreatedData` interface
- `CompletionDocumentsSubmittedData` interface
- `EInvoiceGeneratedData` interface
- `CreditNoteSentData` interface
### ✅ Email Notification Types Added
- `DEALER_PROPOSAL_SUBMITTED`
- `ACTIVITY_CREATED`
- `COMPLETION_DOCUMENTS_SUBMITTED`
- `EINVOICE_GENERATED`
- `CREDIT_NOTE_SENT`
### ✅ Email Notification Service Methods Added
- `sendDealerProposalSubmitted()`
- `sendActivityCreated()`
- `sendCompletionDocumentsSubmitted()`
- `sendEInvoiceGenerated()`
- `sendCreditNoteSent()`
### ✅ Notification Service Integration
- Added to `emailTypeMap`
- Added switch cases in `triggerEmailByType()`
### ✅ Templates Exported
- All templates exported in `index.ts`
---
## 📝 Usage Examples
### 1. Send Proposal Submitted Email
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
title: 'Proposal Submitted',
body: `Dealer ${dealerName} has submitted a proposal for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'proposal_submitted',
priority: 'MEDIUM',
metadata: {
dealerData: {
userId: dealerId,
email: dealerEmail,
displayName: dealerName
},
proposalData: {
totalEstimatedBudget: 50000,
expectedCompletionDate: '2025-02-15',
dealerComments: 'Proposal comments...',
costBreakup: [
{ description: 'Item 1', amount: 20000 },
{ description: 'Item 2', amount: 30000 }
],
submittedAt: new Date()
},
nextApproverId: nextApproverId
}
});
```
### 2. Send Activity Created Email
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, deptLeadId], {
title: 'Activity Created',
body: `Activity "${activityName}" has been created for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'activity_created',
priority: 'MEDIUM',
metadata: {
activityData: {
activityName: 'Dealer Event',
activityType: 'Marketing Event',
location: 'Mumbai',
dealerName: 'ABC Motors',
dealerCode: 'ABC001',
initiatorName: 'John Doe',
departmentLeadName: 'Jane Smith',
ioNumber: 'IO123456',
nextSteps: 'IO confirmation to be made...'
}
}
});
```
### 3. Send Completion Documents Submitted Email
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
title: 'Completion Documents Submitted',
body: `Dealer ${dealerName} has submitted completion documents`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'completion_submitted',
priority: 'MEDIUM',
metadata: {
dealerData: {
userId: dealerId,
email: dealerEmail,
displayName: dealerName
},
completionData: {
activityCompletionDate: new Date('2025-02-10'),
numberOfParticipants: 50,
totalClosedExpenses: 45000,
closedExpenses: [
{ description: 'Expense 1', amount: 20000 },
{ description: 'Expense 2', amount: 25000 }
],
documentsCount: 5,
submittedAt: new Date()
},
nextApproverId: nextApproverId
}
});
```
### 4. Send E-Invoice Generated Email
```typescript
await notificationService.sendToUsers([initiatorId, dealerId, financeId], {
title: 'E-Invoice Generated',
body: `E-Invoice ${invoiceNumber} has been generated for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'einvoice_generated',
priority: 'HIGH',
metadata: {
invoiceData: {
invoiceNumber: 'INV-2025-001',
invoiceDate: new Date(),
dmsNumber: 'DMS123456',
amount: 50000,
dealerName: 'ABC Motors',
dealerCode: 'ABC001',
ioNumber: 'IO123456',
generatedAt: new Date(),
downloadLink: 'https://...'
}
}
});
```
### 5. Send Credit Note Sent Email
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, financeId], {
title: 'Credit Note Sent',
body: `Credit note ${creditNoteNumber} has been sent for request ${requestNumber}`,
requestNumber: requestNumber,
requestId: requestId,
url: `/request/${requestNumber}`,
type: 'credit_note_sent',
priority: 'HIGH',
metadata: {
creditNoteData: {
creditNoteNumber: 'CN-2025-001',
creditNoteDate: new Date(),
creditNoteAmount: 45000,
dealerName: 'ABC Motors',
dealerCode: 'ABC001',
dealerEmail: 'dealer@example.com',
reason: 'Claim settlement',
invoiceNumber: 'INV-2025-001',
sentAt: new Date(),
downloadLink: 'https://...'
}
}
});
```
---
## 🎨 Template Features
All templates include:
- ✅ Royal Enfield branding
- ✅ Responsive design (mobile-friendly)
- ✅ Rich text support (tables, lists, formatting)
- ✅ Table support for cost/expense breakdowns
- ✅ Proper currency formatting (INR)
- ✅ Conditional sections (only show if data available)
- ✅ View Details button with link
- ✅ Email preferences checking
- ✅ Error handling and logging
---
## 🔄 Next Steps for Backend Integration
To use these templates in the dealer claim service, update the notification calls:
### In `dealerClaim.service.ts`:
1. **Proposal Submitted** (line ~1288):
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
type: 'proposal_submitted',
metadata: { dealerData, proposalData, nextApproverId }
});
```
2. **Activity Created** (line ~2141):
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, deptLeadId], {
type: 'activity_created',
metadata: { activityData }
});
```
3. **Completion Submitted** (line ~1393):
```typescript
await notificationService.sendToUsers([initiatorId, nextApproverId], {
type: 'completion_submitted',
metadata: { dealerData, completionData, nextApproverId }
});
```
4. **E-Invoice Generated** (line ~1862):
```typescript
await notificationService.sendToUsers([initiatorId, dealerId, financeId], {
type: 'einvoice_generated',
metadata: { invoiceData }
});
```
5. **Credit Note Sent** (line ~2029):
```typescript
await notificationService.sendToUsers([dealerId, initiatorId, financeId], {
type: 'credit_note_sent',
metadata: { creditNoteData }
});
```
---
## ✅ Testing Checklist
- [ ] Test proposal submitted email with cost breakdown table
- [ ] Test activity created email with IO number
- [ ] Test completion documents email with expense breakdown
- [ ] Test e-invoice email with download link
- [ ] Test credit note email with all fields
- [ ] Verify mobile responsiveness
- [ ] Verify email preferences are respected
- [ ] Test with missing optional fields
- [ ] Verify tables render correctly in email clients
- [ ] Test with additional approvers in workflow
---
## 📚 Related Files
- **Templates**: `Re_Backend/src/emailtemplates/*.template.ts`
- **Types**: `Re_Backend/src/emailtemplates/types.ts`
- **Email Service**: `Re_Backend/src/services/emailNotification.service.ts`
- **Notification Service**: `Re_Backend/src/services/notification.service.ts`
- **Preferences**: `Re_Backend/src/emailtemplates/emailPreferences.helper.ts`
- **Planning Doc**: `Re_Backend/src/emailtemplates/DEALER_CLAIM_EMAIL_TEMPLATES.md`
---
## 🎯 Summary
All 5 dealer claim email templates are now:
- ✅ Created with proper structure and styling
- ✅ Integrated into the notification system
- ✅ Ready to use with proper metadata
- ✅ Supporting additional approvers
- ✅ Mobile responsive
- ✅ Table support for financial data
- ✅ Following Royal Enfield branding guidelines
The templates are ready to be used in the dealer claim workflow service!

View File

@ -1,180 +0,0 @@
/**
* Activity Created Email Template
* Sent when activity is created after Department Lead approval (Step 3)
*/
import { ActivityCreatedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getActivityCreatedEmail(data: ActivityCreatedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Activity Created</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Activity Created Successfully',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
The activity <strong style="color: #333333;">"${data.activityName}"</strong> has been created successfully for request <strong>${data.requestId}</strong>.
</p>
<!-- Activity Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Activity Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
${data.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityDate}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Location:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.location}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName} ${data.dealerCode ? `(${data.dealerCode})` : ''}
</td>
</tr>
${data.ioNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>IO Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.ioNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Created On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.createdDate} at ${data.createdTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.nextSteps ? `
<!-- Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<div style="color: #004085; font-size: 14px; line-height: 1.8;">
${wrapRichText(data.nextSteps)}
</div>
</div>
` : `
<!-- Default Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #004085; font-size: 14px; line-height: 1.8;">
<li>IO confirmation to be made</li>
<li>Dealer will proceed with activity execution</li>
<li>Completion documents will be submitted after activity completion</li>
</ul>
</div>
`}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -1,152 +0,0 @@
/**
* Additional Document Added Email Template
*
* Sent when a document is added to a request by:
* - Initiator Notifies spectators and current approver
* - Spectator Notifies initiator and current approver
* - Approver Notifies initiator and spectators
*/
import { AdditionalDocumentAddedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getAdditionalDocumentAddedEmail(data: AdditionalDocumentAddedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Additional Document Added</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Additional Document Added',
...HeaderStyles.info
}))}
<!-- Content -->
<tr>
<td class="email-content">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong>${data.addedByName}</strong> has added an additional document to the following request:
</p>
<!-- Request Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td class="detail-box" style="padding: 30px;">
<h2 style="margin: 0 0 25px; color: #333333; font-size: 20px; font-weight: 600;">Request Details</h2>
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.requestNumber || data.requestId}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Title:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.requestTitle || 'N/A'}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Document Name:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.documentName}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>File Size:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.fileSize}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Added By:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.addedByName}
</td>
</tr>
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Added On:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.addedDate} at ${data.addedTime}
</td>
</tr>
${data.source ? `
<tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
<strong>Source:</strong>
</td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
${data.source}
</td>
</tr>
` : ''}
</table>
</td>
</tr>
</table>
<!-- Information Box -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">What This Means</h3>
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.8;">
A new document has been added to this request. Please review the document in the request details page to stay updated with the latest information.
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -3,14 +3,14 @@
*/ */
import { ApprovalConfirmationData } from './types'; import { ApprovalConfirmationData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string { export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string {
const commentsSection = data.approverComments ? ` const commentsSection = data.approverComments ? `
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approver Comments:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approver Comments:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #28a745; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #28a745; border-radius: 4px;">
${wrapRichText(data.approverComments)} ${wrapRichText(data.approverComments)}
</div> </div>
</div> </div>
@ -21,24 +21,21 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Request Approved</title> <title>Request Approved</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Request Approved', title: 'Request Approved',
...HeaderStyles.success ...HeaderStyles.success
}))} }))}
<tr> <tr>
<td class="email-content"> <td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;"> <p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>, Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
</p> </p>
@ -49,47 +46,47 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="detail-box" style="padding: 30px;"> <td style="padding: 25px;">
<h2 style="margin: 0 0 25px; color: #155724; font-size: 20px; font-weight: 600;">Request Summary</h2> <h2 style="margin: 0 0 20px; color: #155724; font-size: 18px; font-weight: 600;">Request Summary</h2>
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px; width: 140px;">
<strong>Request ID:</strong> <strong>Request ID:</strong>
</td> </td>
<td style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
${data.requestId} ${data.requestId}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
<strong>Approved By:</strong> <strong>Approved By:</strong>
</td> </td>
<td style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
${data.approverName} ${data.approverName}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
<strong>Approved On:</strong> <strong>Approved On:</strong>
</td> </td>
<td style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
${data.approvalDate} ${data.approvalDate}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
<strong>Time:</strong> <strong>Time:</strong>
</td> </td>
<td style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
${data.approvalTime} ${data.approvalTime}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
<strong>Request Type:</strong> <strong>Request Type:</strong>
</td> </td>
<td style="padding: 10px 0; color: #155724; font-size: 15px;"> <td style="padding: 8px 0; color: #155724; font-size: 14px;">
${data.requestType} ${data.requestType}
</td> </td>
</tr> </tr>

View File

@ -3,7 +3,7 @@
*/ */
import { ApprovalRequestData } from './types'; import { ApprovalRequestData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getApprovalRequestEmail(data: ApprovalRequestData): string { export function getApprovalRequestEmail(data: ApprovalRequestData): string {
@ -22,7 +22,7 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
<!-- Header --> <!-- Header -->
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Approval Request', title: 'Approval Request',
@ -55,16 +55,6 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
${data.requestId} ${data.requestId}
</td> </td>
</tr> </tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr> <tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong> <strong>Initiator:</strong>
@ -102,10 +92,10 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
</tr> </tr>
</table> </table>
<!-- Description (supports rich text HTML including tables) --> <!-- Description (supports rich text HTML) -->
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
${wrapRichText(data.requestDescription)} ${wrapRichText(data.requestDescription)}
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
*/ */
import { ApproverSkippedData } from './types'; import { ApproverSkippedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getApproverSkippedEmail(data: ApproverSkippedData): string { export function getApproverSkippedEmail(data: ApproverSkippedData): string {
@ -12,17 +12,14 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Approver Skipped</title> <title>Approver Skipped</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Approval Level Skipped', title: 'Approval Level Skipped',
...HeaderStyles.infoSecondary ...HeaderStyles.infoSecondary
@ -99,7 +96,7 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Skipping:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Skipping:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #17a2b8; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #17a2b8; border-radius: 4px;">
${wrapRichText(data.skipReason)} ${wrapRichText(data.skipReason)}
</div> </div>
</div> </div>

View File

@ -1,180 +0,0 @@
/**
* Completion Documents Submitted Email Template
* Sent when dealer submits completion documents (Step 4)
*/
import { CompletionDocumentsSubmittedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getCompletionDocumentsSubmittedEmail(data: CompletionDocumentsSubmittedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Completion Documents Submitted</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Completion Documents Submitted',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong style="color: #333333;">${data.dealerName}</strong> has submitted completion documents for the activity <strong>"${data.activityName}"</strong> (Request ${data.requestId}).
</p>
<!-- Completion Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Completion Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Completion Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.activityCompletionDate}
</td>
</tr>
${data.numberOfParticipants ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Participants:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.numberOfParticipants}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Total Expenses:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
${data.documentsCount ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Documents:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.documentsCount} document(s) submitted
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Submitted On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.submittedDate} at ${data.submittedTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.expenseBreakdown ? `
<!-- Expense Breakdown -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Expense Breakdown:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.expenseBreakdown)}
</div>
</div>
` : ''}
<!-- Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.8;">
${data.nextApproverName
? `Completion documents are now pending review by <strong>${data.nextApproverName}</strong>. You will be notified once a decision is made.`
: `Completion documents have been submitted successfully. <strong>Your review and approval is required</strong> to proceed with the final claim approval. Please review the completion documents and take action on this request.`}
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to review the completion documents and take action.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -1,203 +0,0 @@
/**
* Credit Note Sent Email Template
* Sent when credit note is sent to dealer (Step 8)
*/
import { CreditNoteSentData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getCreditNoteSentEmail(data: CreditNoteSentData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Credit Note Sent</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Credit Note Sent',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
A credit note has been generated and sent for the claim request <strong>${data.requestNumber}</strong> (${data.requestId}).
</p>
<!-- Credit Note Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Credit Note Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Request Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestNumber}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Credit Note Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.creditNoteNumber}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Credit Note Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.creditNoteDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Credit Note Amount:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600; color: #28a745;">
${data.creditNoteAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
${data.invoiceNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Related Invoice:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.invoiceNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName} ${data.dealerCode ? `(${data.dealerCode})` : ''}
</td>
</tr>
${data.reason ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; vertical-align: top;">
<strong>Reason:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.reason}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Sent On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.sentDate} at ${data.sentTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.downloadLink ? `
<!-- Download Section -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Download Credit Note</h3>
<p style="margin: 0 0 15px; color: #004085; font-size: 14px; line-height: 1.8;">
You can download the credit note using the link below.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.downloadLink}" class="cta-button" style="display: inline-block; padding: 12px 30px; background-color: #0066cc; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 14px; font-weight: 600;">
Download Credit Note
</a>
</td>
</tr>
</table>
</div>
` : ''}
<!-- Completion Message -->
<div style="padding: 20px; background-color: #d4edda; border-left: 4px solid #28a745; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #155724; font-size: 16px; font-weight: 600;">Claim Process Completed</h3>
<p style="margin: 0; color: #155724; font-size: 14px; line-height: 1.8;">
The credit note has been sent to <strong>${data.dealerEmail}</strong>. The claim management process for this request is now complete.
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Thank you for using the ${data.companyName} Workflow System.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -1,148 +0,0 @@
/**
* Dealer Completion Documents Required Email Template
* Sent when dealer is assigned to submit completion documents (Step 4)
* This is different from proposal required - dealer needs to submit completion documents
*/
import { DealerProposalRequiredData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getDealerCompletionRequiredEmail(data: DealerProposalRequiredData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Completion Documents Required</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Completion Documents Required',
...HeaderStyles.warning
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.dealerName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
The activity <strong style="color: #333333;">"${data.activityName}"</strong> has been approved and is ready for execution. Please submit completion documents after the activity is completed.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Activity Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
${data.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityDate}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Location:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.location}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
</table>
</td>
</tr>
</table>
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">What You Need to Submit:</h3>
<ul style="margin: 0; padding: 0 0 0 20px; color: #666666; font-size: 14px; line-height: 1.6;">
<li style="margin-bottom: 5px;">Activity completion date and details.</li>
<li style="margin-bottom: 5px;">Number of participants.</li>
<li style="margin-bottom: 5px;">Closed expenses breakdown with supporting documents.</li>
<li style="margin-bottom: 5px;">All relevant completion documents and receipts.</li>
<li>Any other supporting documents as required.</li>
</ul>
</div>
${data.dueDate ? `
<p style="margin: 0 0 30px; color: #d9534f; font-size: 16px; line-height: 1.6; font-weight: 600; text-align: center;">
Please submit completion documents by: <strong style="color: #d9534f;">${data.dueDate}</strong>
</p>
` : ''}
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
Submit Completion Documents
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to view the request details and submit your completion documents.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -1,206 +0,0 @@
/**
* Dealer Proposal Required Email Template
* Sent when dealer is assigned to submit proposal (Step 1)
* This is different from approval request - dealer needs to submit, not approve
*/
import { DealerProposalRequiredData } from './types';
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getDealerProposalRequiredEmail(data: DealerProposalRequiredData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Proposal Required</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Proposal Required',
...HeaderStyles.info
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.dealerName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
You have been assigned to submit a proposal for a new claim request. <strong style="color: #333333;">${data.initiatorName}</strong> has created a claim request that requires your proposal submission.
</p>
<!-- Request Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
${data.activityDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityDate}
</td>
</tr>
` : ''}
${data.location ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Location:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.location}
</td>
</tr>
` : ''}
${data.estimatedBudget ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Estimated Budget:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Requestor:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Created On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Time:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTime}
</td>
</tr>
${data.tatHours ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Due Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.dueDate || 'Please submit as soon as possible'}
</td>
</tr>
` : ''}
</table>
</td>
</tr>
</table>
<!-- Description (supports rich text HTML including tables) -->
${data.requestDescription ? `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>
` : ''}
<!-- What You Need to Submit -->
<div style="padding: 20px; background-color: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #856404; font-size: 16px; font-weight: 600;">📋 What You Need to Submit:</h3>
<ul style="margin: 10px 0 0 0; padding-left: 20px; color: #856404; font-size: 14px; line-height: 1.8;">
<li><strong>Proposal Document</strong> - Detailed proposal with requested information</li>
<li><strong>Cost Breakdown</strong> - Itemized list of costs and expenses</li>
<li><strong>Timeline</strong> - Expected completion date or number of days</li>
<li><strong>Supporting Documents</strong> - Any additional documents or attachments</li>
<li><strong>Comments</strong> - Any additional information or clarifications</li>
</ul>
</div>
<!-- Priority Section (dynamic) -->
${getPrioritySection(data.priority)}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
Submit Proposal
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to view the request details and submit your proposal.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -1,178 +0,0 @@
/**
* Dealer Proposal Submitted Email Template
* Sent when dealer submits proposal (Step 1)
*/
import { DealerProposalSubmittedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getDealerProposalSubmittedEmail(data: DealerProposalSubmittedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Proposal Submitted</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'Proposal Submitted',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
<strong style="color: #333333;">${data.dealerName}</strong> has submitted a proposal for the claim request <strong>${data.requestId}</strong>.
</p>
<!-- Proposal Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Proposal Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityType}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Proposed Budget:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.proposalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Expected Completion:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.expectedCompletionDate}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Submitted On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.submittedDate} at ${data.submittedTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.costBreakupSummary ? `
<!-- Cost Breakdown -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Cost Breakdown:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.costBreakupSummary)}
</div>
</div>
` : ''}
${data.dealerComments ? `
<!-- Dealer Comments -->
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Dealer Comments:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.dealerComments)}
</div>
</div>
` : ''}
<!-- Next Steps -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Next Steps</h3>
<p style="margin: 0; color: #004085; font-size: 14px; line-height: 1.8;">
${data.nextApproverName
? `This proposal is now pending review by <strong>${data.nextApproverName}</strong>. You will be notified once a decision is made.`
: `This proposal is now pending your review. Please review and take action on this request.`}
</p>
</div>
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
Click the button above to review the proposal and take action.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -1,187 +0,0 @@
/**
* E-Invoice Generated Email Template
* Sent when e-invoice is generated via DMS (Step 6)
*/
import { EInvoiceGeneratedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getEInvoiceGeneratedEmail(data: EInvoiceGeneratedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>E-Invoice Generated</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
<!-- Header -->
${getEmailHeader(getBrandedHeader({
title: 'E-Invoice Generated',
...HeaderStyles.success
}))}
<!-- Content -->
<tr>
<td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.recipientName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
An e-invoice has been successfully generated for request <strong>${data.requestId}</strong> via DMS integration.
</p>
<!-- Invoice Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Invoice Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>E-Invoice Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.invoiceNumber}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Invoice Date:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.invoiceDate}
</td>
</tr>
${data.dmsNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>DMS Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dmsNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Invoice Amount:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px; font-weight: 600;">
${data.invoiceAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Activity Name:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.activityName}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Dealer:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.dealerName} ${data.dealerCode ? `(${data.dealerCode})` : ''}
</td>
</tr>
${data.ioNumber ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>IO Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.ioNumber}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Generated On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.generatedDate} at ${data.generatedTime}
</td>
</tr>
</table>
</td>
</tr>
</table>
${data.downloadLink ? `
<!-- Download Section -->
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Download Invoice</h3>
<p style="margin: 0 0 15px; color: #004085; font-size: 14px; line-height: 1.8;">
You can download the e-invoice using the link below.
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.downloadLink}" class="cta-button" style="display: inline-block; padding: 12px 30px; background-color: #0066cc; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 14px; font-weight: 600;">
Download E-Invoice
</a>
</td>
</tr>
</table>
</div>
` : ''}
<!-- View Details Button -->
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
The e-invoice has been generated and is ready for processing.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -24,15 +24,7 @@ export enum EmailNotificationType {
REQUEST_CLOSED = 'request_closed', REQUEST_CLOSED = 'request_closed',
WORKFLOW_PAUSED = 'workflow_paused', WORKFLOW_PAUSED = 'workflow_paused',
PARTICIPANT_ADDED = 'participant_added', PARTICIPANT_ADDED = 'participant_added',
SPECTATOR_ADDED = 'spectator_added', APPROVER_SKIPPED = 'approver_skipped'
APPROVER_SKIPPED = 'approver_skipped',
// Dealer Claim Specific
DEALER_PROPOSAL_SUBMITTED = 'dealer_proposal_submitted',
ACTIVITY_CREATED = 'activity_created',
COMPLETION_DOCUMENTS_SUBMITTED = 'completion_documents_submitted',
EINVOICE_GENERATED = 'einvoice_generated',
CREDIT_NOTE_SENT = 'credit_note_sent',
ADDITIONAL_DOCUMENT_ADDED = 'additional_document_added'
} }
/** /**

View File

@ -115,55 +115,6 @@ export function getRichTextStyles(): string {
margin: 12px 0; margin: 12px 0;
} }
/* Table styles for rich text content */
.rich-text-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
background-color: #ffffff;
border: 1px solid #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.rich-text-content table thead {
background-color: #f8f9fa;
}
.rich-text-content table th {
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #1a1a1a;
font-size: 14px;
border-bottom: 2px solid #e9ecef;
background-color: #f8f9fa;
}
.rich-text-content table td {
padding: 10px 15px;
color: #333333;
font-size: 14px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
}
.rich-text-content table tbody tr:last-child td {
border-bottom: none;
}
.rich-text-content table tbody tr:hover {
background-color: #f8f9fa;
}
.rich-text-content table tbody tr:nth-child(even) {
background-color: #fafafa;
}
.rich-text-content table tbody tr:nth-child(even):hover {
background-color: #f0f0f0;
}
/* Mobile adjustments for rich text */ /* Mobile adjustments for rich text */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.rich-text-content p, .rich-text-content p,
@ -176,21 +127,6 @@ export function getRichTextStyles(): string {
.rich-text-content h2 { font-size: 16px !important; } .rich-text-content h2 { font-size: 16px !important; }
.rich-text-content h3 { font-size: 15px !important; } .rich-text-content h3 { font-size: 15px !important; }
.rich-text-content h4 { font-size: 14px !important; } .rich-text-content h4 { font-size: 14px !important; }
/* Make tables scrollable on mobile - keep table structure */
.rich-text-content table {
width: 100% !important;
max-width: 100% !important;
display: table !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.rich-text-content table th,
.rich-text-content table td {
padding: 8px 12px !important;
font-size: 13px !important;
}
} }
</style> </style>
`; `;
@ -199,112 +135,18 @@ export function getRichTextStyles(): string {
/** /**
* Wrap rich text content with proper styling * Wrap rich text content with proper styling
* Use this for descriptions and comments from rich text editors * Use this for descriptions and comments from rich text editors
* Enhanced to support tables with inline styles for email client compatibility
*/ */
export function wrapRichText(htmlContent: string): string { export function wrapRichText(htmlContent: string): string {
if (!htmlContent) return '';
// Process tables to add inline styles for email client compatibility
// Email clients often strip CSS classes, so we need inline styles
let processedContent = htmlContent;
// Add inline styles to tables for better email client support
processedContent = processedContent.replace(
/<table([^>]*)>/gi,
(match, attrs) => {
// Check if style attribute already exists
if (attrs && attrs.includes('style=')) {
return match; // Keep existing styles
}
return `<table${attrs} style="width: 100%; border-collapse: collapse; margin: 12px 0; background-color: #ffffff; border: 1px solid #e9ecef;">`;
}
);
// Add inline styles to table headers
processedContent = processedContent.replace(
/<th([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<th${attrs} style="padding: 12px 15px; text-align: left; font-weight: 600; color: #1a1a1a; font-size: 14px; border-bottom: 2px solid #e9ecef; background-color: #f8f9fa;">`;
}
);
// Add inline styles to table cells
processedContent = processedContent.replace(
/<td([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<td${attrs} style="padding: 10px 15px; color: #333333; font-size: 14px; border-bottom: 1px solid #f0f0f0; vertical-align: top;">`;
}
);
// Add inline styles to table rows for hover effect (email-safe)
processedContent = processedContent.replace(
/<tr([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<tr${attrs} style="border-bottom: 1px solid #f0f0f0;">`;
}
);
// Add inline styles to thead
processedContent = processedContent.replace(
/<thead([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<thead${attrs} style="background-color: #f8f9fa;">`;
}
);
// Add inline styles to tbody (if present)
processedContent = processedContent.replace(
/<tbody([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<tbody${attrs} style="">`;
}
);
// Add inline styles to tfoot (if present)
processedContent = processedContent.replace(
/<tfoot([^>]*)>/gi,
(match, attrs) => {
if (attrs && attrs.includes('style=')) {
return match;
}
return `<tfoot${attrs} style="background-color: #f8f9fa; font-weight: 600;">`;
}
);
return ` return `
<div class="rich-text-content" style="color: #666666; font-size: 14px; line-height: 1.6;"> <div class="rich-text-content" style="color: #666666; font-size: 14px; line-height: 1.6;">
${processedContent} ${htmlContent}
</div> </div>
`; `;
} }
/**
* Get inline styles for email container table
* This ensures width is preserved when emails are forwarded
* Email clients often strip CSS classes, so inline styles are critical
*/
export function getEmailContainerStyles(): string {
return 'width: 95%; max-width: 1200px; min-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);';
}
/** /**
* Generate all email styles (responsive + rich text) * Generate all email styles (responsive + rich text)
* Desktop-first design (optimized for browser) with mobile responsive breakpoints * Optimized for screens up to 600px width
*/ */
export function getResponsiveStyles(): string { export function getResponsiveStyles(): string {
return ` return `
@ -331,100 +173,6 @@ export function getResponsiveStyles(): string {
border-collapse: collapse !important; border-collapse: collapse !important;
} }
/* Desktop-first base styles */
.email-container {
width: 95% !important;
max-width: 1200px !important;
min-width: 600px !important; /* Prevent shrinking below 600px when forwarded */
}
/* Force full width for forwarded emails - use inline styles in templates */
table.email-container {
width: 95% !important;
max-width: 1200px !important;
min-width: 600px !important;
}
/* Wrapper table to force full width even when forwarded */
.email-wrapper {
width: 100% !important;
max-width: 100% !important;
}
.email-content {
padding: 50px 40px;
}
.email-header {
padding: 40px 40px 35px;
}
.email-footer {
padding: 30px 40px;
}
/* Desktop typography */
.header-title {
font-size: 24px;
letter-spacing: 0.5px;
line-height: 1.3;
}
.header-subtitle {
font-size: 14px;
}
/* Desktop detail tables - side by side */
.detail-table {
width: 100%;
}
.detail-table td {
font-size: 15px;
padding: 10px 0;
display: table-cell;
width: auto;
vertical-align: top;
}
.detail-label {
width: 200px;
font-weight: 600;
color: #666666;
}
.detail-box {
padding: 30px;
}
/* Desktop button styles */
.cta-button {
display: inline-block;
padding: 16px 45px;
font-size: 16px;
min-width: 220px;
}
/* Tablet responsive styles */
@media only screen and (max-width: 1200px) {
.email-container {
width: 95% !important;
max-width: 95% !important;
}
.email-content {
padding: 40px 30px !important;
}
.email-header {
padding: 35px 30px 30px !important;
}
.email-footer {
padding: 25px 30px !important;
}
}
/* Mobile responsive styles */ /* Mobile responsive styles */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
/* Container adjustments */ /* Container adjustments */
@ -432,12 +180,11 @@ export function getResponsiveStyles(): string {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
margin: 0 !important; margin: 0 !important;
border-radius: 0 !important;
} }
/* Header adjustments */ /* Header adjustments */
.email-header { .email-header {
padding: 25px 20px 30px !important; padding: 25px 15px 30px !important;
} }
/* Content adjustments */ /* Content adjustments */
@ -460,7 +207,7 @@ export function getResponsiveStyles(): string {
/* Typography adjustments */ /* Typography adjustments */
.header-title { .header-title {
font-size: 20px !important; font-size: 20px !important;
letter-spacing: 0.5px !important; letter-spacing: 1px !important;
line-height: 1.4 !important; line-height: 1.4 !important;
} }
@ -468,22 +215,21 @@ export function getResponsiveStyles(): string {
font-size: 12px !important; font-size: 12px !important;
} }
/* Detail tables - stack on mobile */ /* Detail tables */
.detail-box { .detail-box {
padding: 20px 15px !important; padding: 20px 15px !important;
} }
.detail-table td { .detail-table td {
font-size: 14px !important; font-size: 13px !important;
padding: 8px 0 !important; padding: 6px 0 !important;
display: block !important; display: block !important;
width: 100% !important; width: 100% !important;
} }
.detail-label { .detail-label {
font-weight: 600 !important; font-weight: 600 !important;
margin-bottom: 4px !important; margin-bottom: 2px !important;
width: 100% !important;
} }
/* Button adjustments */ /* Button adjustments */
@ -494,28 +240,27 @@ export function getResponsiveStyles(): string {
padding: 16px 20px !important; padding: 16px 20px !important;
font-size: 16px !important; font-size: 16px !important;
box-sizing: border-box !important; box-sizing: border-box !important;
min-width: auto !important;
} }
/* Section adjustments */ /* Section adjustments */
.info-section { .info-section {
padding: 18px 15px !important; padding: 15px !important;
margin-bottom: 20px !important; margin-bottom: 20px !important;
} }
.section-title { .section-title {
font-size: 16px !important; font-size: 15px !important;
} }
.section-text { .section-text {
font-size: 14px !important; font-size: 13px !important;
line-height: 1.6 !important; line-height: 1.6 !important;
} }
/* List items */ /* List items */
.info-section ul { .info-section ul {
padding-left: 20px !important; padding-left: 15px !important;
font-size: 14px !important; font-size: 13px !important;
} }
.info-section li { .info-section li {
@ -779,7 +524,7 @@ export function getConclusionSection(conclusionRemark?: string): string {
return ` return `
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Conclusion Remarks:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Conclusion Remarks:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6f42c1; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6f42c1; border-radius: 4px;">
${wrapRichText(conclusionRemark)} ${wrapRichText(conclusionRemark)}
</div> </div>
</div> </div>
@ -866,29 +611,3 @@ export function getEmailFooter(config: EmailFooterConfig | string): string {
`; `;
} }
/**
* Get display label for template type
* Maps backend templateType values to user-friendly labels
*
* @param templateType - The template type from the request (e.g., 'CUSTOM', 'DEALER CLAIM', 'TEMPLATE')
* @returns User-friendly label for display in emails
*/
export function getTemplateTypeLabel(templateType?: string): string {
if (!templateType) return 'Non-Templatized';
const upper = templateType.toUpperCase();
// Handle dealer claim variations
if (upper === 'DEALER CLAIM' || upper === 'DEALER_CLAIM' || upper === 'CLAIM-MANAGEMENT' || upper === 'CLAIM_MANAGEMENT') {
return 'Dealer Claim';
}
// Handle template type
if (upper === 'TEMPLATE') {
return 'Template';
}
// Default for CUSTOM or any other value
return 'Non-Templatized';
}

View File

@ -25,15 +25,6 @@ export { getTATBreachedEmail } from './tatBreached.template';
export { getWorkflowPausedEmail } from './workflowPaused.template'; export { getWorkflowPausedEmail } from './workflowPaused.template';
export { getWorkflowResumedEmail } from './workflowResumed.template'; export { getWorkflowResumedEmail } from './workflowResumed.template';
export { getParticipantAddedEmail } from './participantAdded.template'; export { getParticipantAddedEmail } from './participantAdded.template';
export { getSpectatorAddedEmail } from './spectatorAdded.template';
export { getApproverSkippedEmail } from './approverSkipped.template'; export { getApproverSkippedEmail } from './approverSkipped.template';
export { getRequestClosedEmail } from './requestClosed.template'; export { getRequestClosedEmail } from './requestClosed.template';
export { getDealerProposalSubmittedEmail } from './dealerProposalSubmitted.template';
export { getDealerProposalRequiredEmail } from './dealerProposalRequired.template';
export { getDealerCompletionRequiredEmail } from './dealerCompletionRequired.template';
export { getActivityCreatedEmail } from './activityCreated.template';
export { getCompletionDocumentsSubmittedEmail } from './completionDocumentsSubmitted.template';
export { getEInvoiceGeneratedEmail } from './einvoiceGenerated.template';
export { getCreditNoteSentEmail } from './creditNoteSent.template';
export { getAdditionalDocumentAddedEmail } from './additionalDocumentAdded.template';

View File

@ -3,7 +3,7 @@
*/ */
import { MultiApproverRequestData } from './types'; import { MultiApproverRequestData } from './types';
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string { export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
@ -22,7 +22,7 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
<!-- Header --> <!-- Header -->
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Multi-Level Approval Request', title: 'Multi-Level Approval Request',
@ -55,16 +55,6 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
${data.requestId} ${data.requestId}
</td> </td>
</tr> </tr>
${data.requestTitle ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle}
</td>
</tr>
` : ''}
<tr> <tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong> <strong>Initiator:</strong>
@ -106,10 +96,10 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
</table> </table>
</div> </div>
<!-- Description (supports rich text HTML including tables) --> <!-- Description (supports rich text HTML) -->
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
${wrapRichText(data.requestDescription)} ${wrapRichText(data.requestDescription)}
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
*/ */
import { ParticipantAddedData } from './types'; import { ParticipantAddedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getParticipantAddedEmail(data: ParticipantAddedData): string { export function getParticipantAddedEmail(data: ParticipantAddedData): string {
@ -12,17 +12,14 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Added to Request</title> <title>Added to Request</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: `You've Been Added as ${data.participantRole}`, title: `You've Been Added as ${data.participantRole}`,
...HeaderStyles.info ...HeaderStyles.info
@ -107,7 +104,7 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px;">
${wrapRichText(data.requestDescription)} ${wrapRichText(data.requestDescription)}
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
*/ */
import { RejectionNotificationData } from './types'; import { RejectionNotificationData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getRejectionNotificationEmail(data: RejectionNotificationData): string { export function getRejectionNotificationEmail(data: RejectionNotificationData): string {
@ -12,17 +12,14 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Request Rejected</title> <title>Request Rejected</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Request Rejected', title: 'Request Rejected',
...HeaderStyles.error ...HeaderStyles.error
@ -91,7 +88,7 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Rejection:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px;">
${wrapRichText(data.rejectionReason)} ${wrapRichText(data.rejectionReason)}
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
*/ */
import { RequestClosedData } from './types'; import { RequestClosedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getRequestClosedEmail(data: RequestClosedData): string { export function getRequestClosedEmail(data: RequestClosedData): string {
@ -22,7 +22,7 @@ export function getRequestClosedEmail(data: RequestClosedData): string {
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Request Closed', title: 'Request Closed',
...HeaderStyles.complete ...HeaderStyles.complete

View File

@ -3,7 +3,7 @@
*/ */
import { RequestCreatedData } from './types'; import { RequestCreatedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getRequestCreatedEmail(data: RequestCreatedData): string { export function getRequestCreatedEmail(data: RequestCreatedData): string {
@ -22,7 +22,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
<!-- Header --> <!-- Header -->
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Request Created Successfully', title: 'Request Created Successfully',
@ -31,7 +31,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
<!-- Content --> <!-- Content -->
<tr> <tr>
<td class="email-content"> <td class="email-content" style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;"> <p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.initiatorName}</strong>, Dear <strong style="color: #667eea;">${data.initiatorName}</strong>,
</p> </p>
@ -43,55 +43,55 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
<!-- Request Details Box --> <!-- Request Details Box -->
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="detail-box" style="padding: 30px;"> <td style="padding: 25px;">
<h2 style="margin: 0 0 25px; color: #333333; font-size: 20px; font-weight: 600;">Request Summary</h2> <h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Summary</h2>
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request ID:</strong> <strong>Request ID:</strong>
</td> </td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;"> <td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId} ${data.requestId}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong> <strong>Title:</strong>
</td> </td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;"> <td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle || 'N/A'} ${data.requestTitle || 'N/A'}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Request Type:</strong> <strong>Request Type:</strong>
</td> </td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;"> <td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestType} ${data.requestType}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Priority:</strong> <strong>Priority:</strong>
</td> </td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;"> <td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.priority} ${data.priority}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Created On:</strong> <strong>Created On:</strong>
</td> </td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;"> <td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestDate} at ${data.requestTime} ${data.requestDate} at ${data.requestTime}
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;"> <td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Total Approvers:</strong> <strong>Total Approvers:</strong>
</td> </td>
<td style="padding: 10px 0; color: #333333; font-size: 15px;"> <td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.totalApprovers} ${data.totalApprovers}
</td> </td>
</tr> </tr>

View File

@ -1,155 +0,0 @@
/**
* Spectator Added Email Template
*
* Sent when a user is added as a spectator to a request
*/
import { SpectatorAddedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
import { getBrandedHeader } from './branding.config';
export function getSpectatorAddedEmail(data: SpectatorAddedData): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Added as Spectator</title>
${getResponsiveStyles()}
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({
title: "You've Been Added as Spectator",
...HeaderStyles.info
}))}
<tr>
<td style="padding: 40px 30px;">
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
Dear <strong style="color: #667eea;">${data.spectatorName}</strong>,
</p>
<p style="margin: 0 0 30px; color: #666666; font-size: 16px; line-height: 1.6;">
You have been added as a <strong>Spectator</strong> to the following request${data.addedByName ? ` by <strong>${data.addedByName}</strong>` : ''}. ${getRoleDescription('Spectator')}
</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 25px;">
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
<strong>Request Number:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestId}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Title:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestTitle || 'N/A'}
</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Initiator:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.initiatorName}
</td>
</tr>
${data.requestType ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Request Type:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.requestType}
</td>
</tr>
` : ''}
${data.currentStatus ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Current Status:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.currentStatus}
</td>
</tr>
` : ''}
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Your Role:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
<strong style="color: #667eea;">Spectator</strong>
</td>
</tr>
${data.addedDate ? `
<tr>
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
<strong>Added On:</strong>
</td>
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
${data.addedDate}${data.addedTime ? ` at ${data.addedTime}` : ''}
</td>
</tr>
` : ''}
</table>
</td>
</tr>
</table>
${data.requestDescription ? `
<div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Description:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
${wrapRichText(data.requestDescription)}
</div>
</div>
` : ''}
<div style="padding: 20px; background-color: #e7f3ff; border-left: 4px solid #0066cc; border-radius: 4px; margin-bottom: 30px;">
<h3 style="margin: 0 0 10px; color: #004085; font-size: 16px; font-weight: 600;">Your Permissions as Spectator</h3>
${getPermissionsContent('Spectator')}
</div>
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
<tr>
<td style="text-align: center;">
<a href="${data.viewDetailsLink}" class="cta-button" style="display: inline-block; padding: 15px 40px; background-color: #1a1a1a; color: #ffffff; text-decoration: none; text-align: center; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); min-width: 200px;">
View Request Details
</a>
</td>
</tr>
</table>
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 1.6; text-align: center;">
You can now access this request, view documents, and participate in discussions.
</p>
</td>
</tr>
${getEmailFooter(data.companyName)}
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}

View File

@ -3,7 +3,7 @@
*/ */
import { TATBreachedData } from './types'; import { TATBreachedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getTATBreachedEmail(data: TATBreachedData): string { export function getTATBreachedEmail(data: TATBreachedData): string {
@ -12,17 +12,14 @@ export function getTATBreachedEmail(data: TATBreachedData): string {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>TAT Breached - Urgent Action Required</title> <title>TAT Breached - Urgent Action Required</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'TAT Breached', title: 'TAT Breached',
subtitle: 'Immediate Action Required', subtitle: 'Immediate Action Required',

View File

@ -3,7 +3,7 @@
*/ */
import { TATReminderData } from './types'; import { TATReminderData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
/** /**
@ -52,7 +52,7 @@ export function getTATReminderEmail(data: TATReminderData): string {
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" class="email-container" style="width: 600px; max-width: 100%; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'TAT Reminder', title: 'TAT Reminder',
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`, subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,

View File

@ -137,112 +137,3 @@ export interface RequestClosedData extends BaseEmailData {
documentsCount: number; documentsCount: number;
} }
export interface SpectatorAddedData extends BaseEmailData {
spectatorName: string;
addedByName?: string;
initiatorName: string;
requestType?: string;
currentStatus?: string;
addedDate?: string;
addedTime?: string;
requestDescription?: string;
}
// Dealer Claim Specific Templates
export interface DealerProposalSubmittedData extends BaseEmailData {
dealerName: string;
activityName: string;
activityType: string;
proposalBudget: number;
expectedCompletionDate: string;
dealerComments?: string;
costBreakupSummary?: string; // Summary or table of cost items
submittedDate: string;
submittedTime: string;
nextApproverName?: string;
}
export interface ActivityCreatedData extends BaseEmailData {
activityName: string;
activityType: string;
activityDate?: string;
location: string;
dealerName: string;
dealerCode: string;
initiatorName: string;
departmentLeadName?: string;
ioNumber?: string;
createdDate: string;
createdTime: string;
nextSteps?: string;
}
export interface CompletionDocumentsSubmittedData extends BaseEmailData {
dealerName: string;
activityName: string;
activityCompletionDate: string;
numberOfParticipants?: number;
totalClosedExpenses: number;
expenseBreakdown?: string; // Summary or table of expenses
documentsCount?: number;
submittedDate: string;
submittedTime: string;
nextApproverName?: string;
}
export interface EInvoiceGeneratedData extends BaseEmailData {
invoiceNumber: string;
invoiceDate: string;
dmsNumber?: string;
invoiceAmount: number;
dealerName: string;
dealerCode: string;
activityName: string;
ioNumber?: string;
generatedDate: string;
generatedTime: string;
downloadLink?: string;
}
export interface CreditNoteSentData extends BaseEmailData {
creditNoteNumber: string;
creditNoteDate: string;
creditNoteAmount: number;
dealerName: string;
dealerCode: string;
dealerEmail: string;
activityName: string;
requestNumber: string;
reason?: string;
invoiceNumber?: string;
sentDate: string;
sentTime: string;
downloadLink?: string;
}
export interface DealerProposalRequiredData extends BaseEmailData {
dealerName: string;
initiatorName: string;
activityName: string;
activityType: string;
activityDate?: string;
location?: string;
estimatedBudget?: number;
requestDate: string;
requestTime: string;
requestDescription: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
tatHours?: number;
dueDate?: string;
}
export interface AdditionalDocumentAddedData extends BaseEmailData {
documentName: string;
fileSize: string;
addedByName: string;
addedDate: string;
addedTime: string;
requestNumber?: string;
source?: string; // 'Documents Tab' or 'Work Notes'
}

View File

@ -3,7 +3,7 @@
*/ */
import { WorkflowPausedData } from './types'; import { WorkflowPausedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getWorkflowPausedEmail(data: WorkflowPausedData): string { export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
@ -12,17 +12,14 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Workflow Paused</title> <title>Workflow Paused</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Workflow Paused', title: 'Workflow Paused',
...HeaderStyles.neutral ...HeaderStyles.neutral
@ -91,7 +88,7 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Pause:</h3> <h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Reason for Pause:</h3>
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6c757d; border-radius: 4px; overflow-x: auto;"> <div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6c757d; border-radius: 4px;">
${wrapRichText(data.pauseReason)} ${wrapRichText(data.pauseReason)}
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
*/ */
import { WorkflowResumedData } from './types'; import { WorkflowResumedData } from './types';
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection } from './helpers';
import { getBrandedHeader } from './branding.config'; import { getBrandedHeader } from './branding.config';
export function getWorkflowResumedEmail(data: WorkflowResumedData): string { export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
@ -12,17 +12,14 @@ export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="format-detection" content="telephone=no">
<title>Workflow Resumed</title> <title>Workflow Resumed</title>
${getResponsiveStyles()}
</head> </head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;"> <body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f4f4f4;" cellpadding="0" cellspacing="0">
<tr> <tr>
<td style="padding: 40px 0;"> <td style="padding: 40px 0;">
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0"> <table role="presentation" style="width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);" cellpadding="0" cellspacing="0">
${getEmailHeader(getBrandedHeader({ ${getEmailHeader(getBrandedHeader({
title: 'Workflow Resumed', title: 'Workflow Resumed',
...HeaderStyles.success ...HeaderStyles.success

View File

@ -1,322 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
import { Sequelize } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
// Ensure uuid-ossp extension is enabled (required for uuid_generate_v4())
await queryInterface.sequelize.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"');
// Create dealers table with all fields from sample data
await queryInterface.createTable('dealers', {
dealer_id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: Sequelize.literal('uuid_generate_v4()')
},
sales_code: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Sales Code'
},
service_code: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Service Code'
},
gear_code: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Gear Code'
},
gma_code: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'GMA CODE'
},
region: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Region'
},
dealership: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Dealership name'
},
state: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'State'
},
district: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'District'
},
city: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'City'
},
location: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Location'
},
city_category_pst: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'City category (PST)'
},
layout_format: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Layout format'
},
tier_city_category: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'TIER City Category'
},
on_boarding_charges: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'On Boarding Charges (stored as text to allow text values)'
},
date: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'DATE (stored as text to avoid format validation)'
},
single_format_month_year: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Single Format of Month/Year (stored as text)'
},
domain_id: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Domain Id'
},
replacement: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Replacement (stored as text to allow longer values)'
},
termination_resignation_status: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Termination / Resignation under Proposal or Evaluation'
},
date_of_termination_resignation: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Date Of termination/ resignation (stored as text to avoid format validation)'
},
last_date_of_operations: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Last date of operations (stored as text to avoid format validation)'
},
old_codes: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Old Codes'
},
branch_details: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Branch Details'
},
dealer_principal_name: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Dealer Principal Name'
},
dealer_principal_email_id: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Dealer Principal Email Id'
},
dp_contact_number: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'DP CONTACT NUMBER (stored as text to allow multiple numbers)'
},
dp_contacts: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'DP CONTACTS (stored as text to allow multiple contacts)'
},
showroom_address: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Showroom Address'
},
showroom_pincode: {
type: DataTypes.STRING(10),
allowNull: true,
comment: 'Showroom Pincode'
},
workshop_address: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Workshop Address'
},
workshop_pincode: {
type: DataTypes.STRING(10),
allowNull: true,
comment: 'Workshop Pincode'
},
location_district: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'Location / District'
},
state_workshop: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'State (for workshop)'
},
no_of_studios: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
comment: 'No Of Studios'
},
website_update: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Website update (stored as text to allow longer values)'
},
gst: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'GST'
},
pan: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'PAN'
},
firm_type: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'Firm Type'
},
prop_managing_partners_directors: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Prop. / Managing Partners / Managing Directors'
},
total_prop_partners_directors: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Total Prop. / Partners / Directors'
},
docs_folder_link: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'DOCS Folder Link'
},
workshop_gma_codes: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Workshop GMA Codes'
},
existing_new: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Existing / New'
},
dlrcode: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'dlrcode'
},
is_active: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
comment: 'Whether the dealer is currently active'
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
}
});
// Create indexes
await queryInterface.addIndex('dealers', ['sales_code'], {
name: 'idx_dealers_sales_code',
unique: false
});
await queryInterface.addIndex('dealers', ['service_code'], {
name: 'idx_dealers_service_code',
unique: false
});
await queryInterface.addIndex('dealers', ['gma_code'], {
name: 'idx_dealers_gma_code',
unique: false
});
await queryInterface.addIndex('dealers', ['domain_id'], {
name: 'idx_dealers_domain_id',
unique: false
});
await queryInterface.addIndex('dealers', ['region'], {
name: 'idx_dealers_region',
unique: false
});
await queryInterface.addIndex('dealers', ['state'], {
name: 'idx_dealers_state',
unique: false
});
await queryInterface.addIndex('dealers', ['city'], {
name: 'idx_dealers_city',
unique: false
});
await queryInterface.addIndex('dealers', ['district'], {
name: 'idx_dealers_district',
unique: false
});
await queryInterface.addIndex('dealers', ['dlrcode'], {
name: 'idx_dealers_dlrcode',
unique: false
});
await queryInterface.addIndex('dealers', ['is_active'], {
name: 'idx_dealers_is_active',
unique: false
});
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Drop indexes first
await queryInterface.removeIndex('dealers', 'idx_dealers_sales_code');
await queryInterface.removeIndex('dealers', 'idx_dealers_service_code');
await queryInterface.removeIndex('dealers', 'idx_dealers_gma_code');
await queryInterface.removeIndex('dealers', 'idx_dealers_domain_id');
await queryInterface.removeIndex('dealers', 'idx_dealers_region');
await queryInterface.removeIndex('dealers', 'idx_dealers_state');
await queryInterface.removeIndex('dealers', 'idx_dealers_city');
await queryInterface.removeIndex('dealers', 'idx_dealers_district');
await queryInterface.removeIndex('dealers', 'idx_dealers_dlrcode');
await queryInterface.removeIndex('dealers', 'idx_dealers_is_active');
// Drop table
await queryInterface.dropTable('dealers');
}

View File

@ -1,83 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
/**
* Migration to create activity_types table for claim management activity types
* Admin can manage activity types similar to holiday management
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
await queryInterface.createTable('activity_types', {
activity_type_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: {
type: DataTypes.STRING(200),
allowNull: false,
unique: true,
comment: 'Activity type title/name (e.g., "Riders Mania Claims", "Legal Claims Reimbursement")'
},
item_code: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
comment: 'Optional item code for the activity type'
},
taxation_type: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
comment: 'Optional taxation type for the activity'
},
sap_ref_no: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
comment: 'Optional SAP reference number'
},
is_active: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: 'Whether this activity type is currently active/available for selection'
},
created_by: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'user_id'
},
comment: 'Admin user who created this activity type'
},
updated_by: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'users',
key: 'user_id'
},
comment: 'Admin user who last updated this activity type'
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
},
updated_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
});
// Indexes for performance
await queryInterface.sequelize.query('CREATE UNIQUE INDEX IF NOT EXISTS "activity_types_title_unique" ON "activity_types" ("title");');
await queryInterface.sequelize.query('CREATE INDEX IF NOT EXISTS "activity_types_is_active" ON "activity_types" ("is_active");');
await queryInterface.sequelize.query('CREATE INDEX IF NOT EXISTS "activity_types_item_code" ON "activity_types" ("item_code");');
await queryInterface.sequelize.query('CREATE INDEX IF NOT EXISTS "activity_types_created_by" ON "activity_types" ("created_by");');
}
export async function down(queryInterface: QueryInterface): Promise<void> {
await queryInterface.dropTable('activity_types');
}

View File

@ -1,134 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
export const up = async (queryInterface: QueryInterface) => {
// 1. Drop and recreate the enum type for snapshot_type to ensure all values are included
// This ensures APPROVE is always present when table is recreated
// Note: Table should be dropped manually before running this migration
try {
await queryInterface.sequelize.query(`
DO $$
BEGIN
-- Drop enum if it exists (cascade will handle any dependencies)
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'enum_dealer_claim_history_snapshot_type') THEN
DROP TYPE IF EXISTS enum_dealer_claim_history_snapshot_type CASCADE;
END IF;
-- Create enum with all values including APPROVE
CREATE TYPE enum_dealer_claim_history_snapshot_type AS ENUM ('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE');
END $$;
`);
} catch (error) {
// If enum creation fails, log error but continue
console.error('Enum creation error:', error);
throw error;
}
// 2. Create new simplified level-based dealer_claim_history table
await queryInterface.createTable('dealer_claim_history', {
history_id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
request_id: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'workflow_requests',
key: 'request_id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
},
approval_level_id: {
type: DataTypes.UUID,
allowNull: true, // Nullable for workflow-level snapshots
references: {
model: 'approval_levels',
key: 'level_id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
level_number: {
type: DataTypes.INTEGER,
allowNull: true, // Nullable for workflow-level snapshots
comment: 'Level number for easier querying (e.g., 1=Dealer, 3=Dept Lead, 4/5=Completion)'
},
level_name: {
type: DataTypes.STRING(255),
allowNull: true, // Nullable for workflow-level snapshots
comment: 'Level name for consistent matching (e.g., "Dealer Proposal Submission", "Department Lead Approval")'
},
version: {
type: DataTypes.INTEGER,
allowNull: false,
comment: 'Version number for this specific level (starts at 1 per level)'
},
snapshot_type: {
type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'),
allowNull: false,
comment: 'Type of snapshot: PROPOSAL (Step 1), COMPLETION (Step 4/5), INTERNAL_ORDER (Step 3), WORKFLOW (general), APPROVE (approver actions with comments)'
},
snapshot_data: {
type: DataTypes.JSONB,
allowNull: false,
comment: 'JSON object containing all snapshot data specific to this level and type. Structure varies by snapshot_type.'
},
change_reason: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Reason for this version change (e.g., "Revision Requested: ...")'
},
changed_by: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'users',
key: 'user_id'
}
},
created_at: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW
}
});
// Add indexes for efficient querying
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_number', 'version'], {
name: 'idx_history_request_level_version'
});
await queryInterface.addIndex('dealer_claim_history', ['approval_level_id', 'version'], {
name: 'idx_history_level_version'
});
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'snapshot_type'], {
name: 'idx_history_request_type'
});
await queryInterface.addIndex('dealer_claim_history', ['snapshot_type', 'level_number'], {
name: 'idx_history_type_level'
});
await queryInterface.addIndex('dealer_claim_history', ['request_id', 'level_name'], {
name: 'idx_history_request_level_name'
});
await queryInterface.addIndex('dealer_claim_history', ['level_name', 'snapshot_type'], {
name: 'idx_history_level_name_type'
});
// Index for JSONB queries on snapshot_data
await queryInterface.addIndex('dealer_claim_history', ['snapshot_type'], {
name: 'idx_history_snapshot_type',
using: 'BTREE'
});
};
export const down = async (queryInterface: QueryInterface) => {
// Note: Table should be dropped manually
// Drop the enum type
try {
await queryInterface.sequelize.query(`
DROP TYPE IF EXISTS enum_dealer_claim_history_snapshot_type CASCADE;
`);
} catch (error) {
console.warn('Enum drop warning:', error);
}
};

View File

@ -1,115 +0,0 @@
import { QueryInterface, DataTypes } from 'sequelize';
export async function up(queryInterface: QueryInterface): Promise<void> {
try {
const tableDescription = await queryInterface.describeTable('workflow_templates');
// 1. Rename id -> template_id
if (tableDescription.id && !tableDescription.template_id) {
console.log('Renaming id to template_id...');
await queryInterface.renameColumn('workflow_templates', 'id', 'template_id');
}
// 2. Rename name -> template_name
if (tableDescription.name && !tableDescription.template_name) {
console.log('Renaming name to template_name...');
await queryInterface.renameColumn('workflow_templates', 'name', 'template_name');
}
// 3. Rename description -> template_description
if (tableDescription.description && !tableDescription.template_description) {
console.log('Renaming description to template_description...');
await queryInterface.renameColumn('workflow_templates', 'description', 'template_description');
}
// 4. Rename category -> template_category
if (tableDescription.category && !tableDescription.template_category) {
console.log('Renaming category to template_category...');
await queryInterface.renameColumn('workflow_templates', 'category', 'template_category');
}
// 5. Rename suggested_sla -> default_tat_hours
if (tableDescription.suggested_sla && !tableDescription.default_tat_hours) {
console.log('Renaming suggested_sla to default_tat_hours...');
await queryInterface.renameColumn('workflow_templates', 'suggested_sla', 'default_tat_hours');
}
// 6. Add missing columns
if (!tableDescription.template_code) {
console.log('Adding template_code column...');
await queryInterface.addColumn('workflow_templates', 'template_code', {
type: DataTypes.STRING(50),
allowNull: true,
unique: true
});
}
if (!tableDescription.workflow_type) {
console.log('Adding workflow_type column...');
await queryInterface.addColumn('workflow_templates', 'workflow_type', {
type: DataTypes.STRING(50),
allowNull: true
});
}
if (!tableDescription.approval_levels_config) {
console.log('Adding approval_levels_config column...');
await queryInterface.addColumn('workflow_templates', 'approval_levels_config', {
type: DataTypes.JSONB,
allowNull: true
});
}
if (!tableDescription.form_steps_config) {
console.log('Adding form_steps_config column...');
await queryInterface.addColumn('workflow_templates', 'form_steps_config', {
type: DataTypes.JSONB,
allowNull: true
});
}
if (!tableDescription.user_field_mappings) {
console.log('Adding user_field_mappings column...');
await queryInterface.addColumn('workflow_templates', 'user_field_mappings', {
type: DataTypes.JSONB,
allowNull: true
});
}
if (!tableDescription.dynamic_approver_config) {
console.log('Adding dynamic_approver_config column...');
await queryInterface.addColumn('workflow_templates', 'dynamic_approver_config', {
type: DataTypes.JSONB,
allowNull: true
});
}
if (!tableDescription.is_system_template) {
console.log('Adding is_system_template column...');
await queryInterface.addColumn('workflow_templates', 'is_system_template', {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
});
}
if (!tableDescription.usage_count) {
console.log('Adding usage_count column...');
await queryInterface.addColumn('workflow_templates', 'usage_count', {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
});
}
console.log('✅ Schema validation/fix complete');
} catch (error) {
console.error('Error in schema fix migration:', error);
throw error;
}
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Revert is complex/risky effectively, skipping for this fix-forward migration
}

View File

@ -1,127 +0,0 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { User } from './User';
interface ActivityTypeAttributes {
activityTypeId: string;
title: string;
itemCode?: string;
taxationType?: string;
sapRefNo?: string;
isActive: boolean;
createdBy: string;
updatedBy?: string;
createdAt: Date;
updatedAt: Date;
}
interface ActivityTypeCreationAttributes extends Optional<ActivityTypeAttributes, 'activityTypeId' | 'itemCode' | 'taxationType' | 'sapRefNo' | 'isActive' | 'updatedBy' | 'createdAt' | 'updatedAt'> {}
class ActivityType extends Model<ActivityTypeAttributes, ActivityTypeCreationAttributes> implements ActivityTypeAttributes {
public activityTypeId!: string;
public title!: string;
public itemCode?: string;
public taxationType?: string;
public sapRefNo?: string;
public isActive!: boolean;
public createdBy!: string;
public updatedBy?: string;
public createdAt!: Date;
public updatedAt!: Date;
// Associations
public creator?: User;
public updater?: User;
}
ActivityType.init(
{
activityTypeId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'activity_type_id'
},
title: {
type: DataTypes.STRING(200),
allowNull: false,
unique: true,
field: 'title'
},
itemCode: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
field: 'item_code'
},
taxationType: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
field: 'taxation_type'
},
sapRefNo: {
type: DataTypes.STRING(100),
allowNull: true,
defaultValue: null,
field: 'sap_ref_no'
},
isActive: {
type: DataTypes.BOOLEAN,
defaultValue: true,
field: 'is_active'
},
createdBy: {
type: DataTypes.UUID,
allowNull: false,
field: 'created_by'
},
updatedBy: {
type: DataTypes.UUID,
allowNull: true,
field: 'updated_by'
},
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: 'ActivityType',
tableName: 'activity_types',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{ fields: ['title'], unique: true },
{ fields: ['is_active'] },
{ fields: ['item_code'] },
{ fields: ['created_by'] }
]
}
);
// Associations
ActivityType.belongsTo(User, {
as: 'creator',
foreignKey: 'createdBy',
targetKey: 'userId'
});
ActivityType.belongsTo(User, {
as: 'updater',
foreignKey: 'updatedBy',
targetKey: 'userId'
});
export { ActivityType };

View File

@ -1,442 +0,0 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database';
interface DealerAttributes {
dealerId: string;
salesCode?: string | null;
serviceCode?: string | null;
gearCode?: string | null;
gmaCode?: string | null;
region?: string | null;
dealership?: string | null;
state?: string | null;
district?: string | null;
city?: string | null;
location?: string | null;
cityCategoryPst?: string | null;
layoutFormat?: string | null;
tierCityCategory?: string | null;
onBoardingCharges?: string | null;
date?: string | null;
singleFormatMonthYear?: string | null;
domainId?: string | null;
replacement?: string | null;
terminationResignationStatus?: string | null;
dateOfTerminationResignation?: string | null;
lastDateOfOperations?: string | null;
oldCodes?: string | null;
branchDetails?: string | null;
dealerPrincipalName?: string | null;
dealerPrincipalEmailId?: string | null;
dpContactNumber?: string | null;
dpContacts?: string | null;
showroomAddress?: string | null;
showroomPincode?: string | null;
workshopAddress?: string | null;
workshopPincode?: string | null;
locationDistrict?: string | null;
stateWorkshop?: string | null;
noOfStudios?: number | null;
websiteUpdate?: string | null;
gst?: string | null;
pan?: string | null;
firmType?: string | null;
propManagingPartnersDirectors?: string | null;
totalPropPartnersDirectors?: string | null;
docsFolderLink?: string | null;
workshopGmaCodes?: string | null;
existingNew?: string | null;
dlrcode?: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
interface DealerCreationAttributes extends Optional<DealerAttributes, 'dealerId' | 'isActive' | 'createdAt' | 'updatedAt'> {}
class Dealer extends Model<DealerAttributes, DealerCreationAttributes> implements DealerAttributes {
public dealerId!: string;
public salesCode?: string | null;
public serviceCode?: string | null;
public gearCode?: string | null;
public gmaCode?: string | null;
public region?: string | null;
public dealership?: string | null;
public state?: string | null;
public district?: string | null;
public city?: string | null;
public location?: string | null;
public cityCategoryPst?: string | null;
public layoutFormat?: string | null;
public tierCityCategory?: string | null;
public onBoardingCharges?: string | null;
public date?: string | null;
public singleFormatMonthYear?: string | null;
public domainId?: string | null;
public replacement?: string | null;
public terminationResignationStatus?: string | null;
public dateOfTerminationResignation?: string | null;
public lastDateOfOperations?: string | null;
public oldCodes?: string | null;
public branchDetails?: string | null;
public dealerPrincipalName?: string | null;
public dealerPrincipalEmailId?: string | null;
public dpContactNumber?: string | null;
public dpContacts?: string | null;
public showroomAddress?: string | null;
public showroomPincode?: string | null;
public workshopAddress?: string | null;
public workshopPincode?: string | null;
public locationDistrict?: string | null;
public stateWorkshop?: string | null;
public noOfStudios?: number | null;
public websiteUpdate?: string | null;
public gst?: string | null;
public pan?: string | null;
public firmType?: string | null;
public propManagingPartnersDirectors?: string | null;
public totalPropPartnersDirectors?: string | null;
public docsFolderLink?: string | null;
public workshopGmaCodes?: string | null;
public existingNew?: string | null;
public dlrcode?: string | null;
public isActive!: boolean;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
Dealer.init(
{
dealerId: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
field: 'dealer_id'
},
salesCode: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'sales_code',
comment: 'Sales Code'
},
serviceCode: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'service_code',
comment: 'Service Code'
},
gearCode: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'gear_code',
comment: 'Gear Code'
},
gmaCode: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'gma_code',
comment: 'GMA CODE'
},
region: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Region'
},
dealership: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Dealership name'
},
state: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'State'
},
district: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'District'
},
city: {
type: DataTypes.STRING(100),
allowNull: true,
comment: 'City'
},
location: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Location'
},
cityCategoryPst: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'city_category_pst',
comment: 'City category (PST)'
},
layoutFormat: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'layout_format',
comment: 'Layout format'
},
tierCityCategory: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'tier_city_category',
comment: 'TIER City Category'
},
onBoardingCharges: {
type: DataTypes.TEXT,
allowNull: true,
field: 'on_boarding_charges',
comment: 'On Boarding Charges (stored as text to allow text values)'
},
date: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'DATE (stored as text to avoid format validation)'
},
singleFormatMonthYear: {
type: DataTypes.TEXT,
allowNull: true,
field: 'single_format_month_year',
comment: 'Single Format of Month/Year (stored as text)'
},
domainId: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'domain_id',
comment: 'Domain Id'
},
replacement: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Replacement (stored as text to allow longer values)'
},
terminationResignationStatus: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'termination_resignation_status',
comment: 'Termination / Resignation under Proposal or Evaluation'
},
dateOfTerminationResignation: {
type: DataTypes.TEXT,
allowNull: true,
field: 'date_of_termination_resignation',
comment: 'Date Of termination/ resignation (stored as text to avoid format validation)'
},
lastDateOfOperations: {
type: DataTypes.TEXT,
allowNull: true,
field: 'last_date_of_operations',
comment: 'Last date of operations (stored as text to avoid format validation)'
},
oldCodes: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'old_codes',
comment: 'Old Codes'
},
branchDetails: {
type: DataTypes.TEXT,
allowNull: true,
field: 'branch_details',
comment: 'Branch Details'
},
dealerPrincipalName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'dealer_principal_name',
comment: 'Dealer Principal Name'
},
dealerPrincipalEmailId: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'dealer_principal_email_id',
comment: 'Dealer Principal Email Id'
},
dpContactNumber: {
type: DataTypes.TEXT,
allowNull: true,
field: 'dp_contact_number',
comment: 'DP CONTACT NUMBER (stored as text to allow multiple numbers)'
},
dpContacts: {
type: DataTypes.TEXT,
allowNull: true,
field: 'dp_contacts',
comment: 'DP CONTACTS (stored as text to allow multiple contacts)'
},
showroomAddress: {
type: DataTypes.TEXT,
allowNull: true,
field: 'showroom_address',
comment: 'Showroom Address'
},
showroomPincode: {
type: DataTypes.STRING(10),
allowNull: true,
field: 'showroom_pincode',
comment: 'Showroom Pincode'
},
workshopAddress: {
type: DataTypes.TEXT,
allowNull: true,
field: 'workshop_address',
comment: 'Workshop Address'
},
workshopPincode: {
type: DataTypes.STRING(10),
allowNull: true,
field: 'workshop_pincode',
comment: 'Workshop Pincode'
},
locationDistrict: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'location_district',
comment: 'Location / District'
},
stateWorkshop: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'state_workshop',
comment: 'State (for workshop)'
},
noOfStudios: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
field: 'no_of_studios',
comment: 'No Of Studios'
},
websiteUpdate: {
type: DataTypes.TEXT,
allowNull: true,
field: 'website_update',
comment: 'Website update (stored as text to allow longer values)'
},
gst: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'GST'
},
pan: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'PAN'
},
firmType: {
type: DataTypes.STRING(100),
allowNull: true,
field: 'firm_type',
comment: 'Firm Type'
},
propManagingPartnersDirectors: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'prop_managing_partners_directors',
comment: 'Prop. / Managing Partners / Managing Directors'
},
totalPropPartnersDirectors: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'total_prop_partners_directors',
comment: 'Total Prop. / Partners / Directors'
},
docsFolderLink: {
type: DataTypes.TEXT,
allowNull: true,
field: 'docs_folder_link',
comment: 'DOCS Folder Link'
},
workshopGmaCodes: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'workshop_gma_codes',
comment: 'Workshop GMA Codes'
},
existingNew: {
type: DataTypes.STRING(50),
allowNull: true,
field: 'existing_new',
comment: 'Existing / New'
},
dlrcode: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'dlrcode'
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'is_active',
comment: 'Whether the dealer is currently active'
},
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,
tableName: 'dealers',
modelName: 'Dealer',
timestamps: true,
underscored: true,
indexes: [
{
fields: ['sales_code'],
name: 'idx_dealers_sales_code'
},
{
fields: ['service_code'],
name: 'idx_dealers_service_code'
},
{
fields: ['gma_code'],
name: 'idx_dealers_gma_code'
},
{
fields: ['domain_id'],
name: 'idx_dealers_domain_id'
},
{
fields: ['region'],
name: 'idx_dealers_region'
},
{
fields: ['state'],
name: 'idx_dealers_state'
},
{
fields: ['city'],
name: 'idx_dealers_city'
},
{
fields: ['district'],
name: 'idx_dealers_district'
},
{
fields: ['dlrcode'],
name: 'idx_dealers_dlrcode'
},
{
fields: ['is_active'],
name: 'idx_dealers_is_active'
}
]
}
);
export { Dealer };
export type { DealerAttributes, DealerCreationAttributes };

View File

@ -1,190 +0,0 @@
import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '@config/database';
import { WorkflowRequest } from './WorkflowRequest';
import { ApprovalLevel } from './ApprovalLevel';
import { User } from './User';
export enum SnapshotType {
PROPOSAL = 'PROPOSAL',
COMPLETION = 'COMPLETION',
INTERNAL_ORDER = 'INTERNAL_ORDER',
WORKFLOW = 'WORKFLOW',
APPROVE = 'APPROVE'
}
// Type definitions for snapshot data structures
export interface ProposalSnapshotData {
documentUrl?: string;
totalBudget?: number;
comments?: string;
expectedCompletionDate?: string;
costItems?: Array<{
description: string;
amount: number;
order: number;
}>;
}
export interface CompletionSnapshotData {
documentUrl?: string;
totalExpenses?: number;
comments?: string;
expenses?: Array<{
description: string;
amount: number;
}>;
}
export interface IOSnapshotData {
ioNumber?: string;
blockedAmount?: number;
availableBalance?: number;
remainingBalance?: number;
sapDocumentNumber?: string;
}
export interface WorkflowSnapshotData {
status?: string;
currentLevel?: number;
}
export interface ApprovalSnapshotData {
action: 'APPROVE' | 'REJECT';
comments?: string;
rejectionReason?: string;
approverName?: string;
approverEmail?: string;
levelName?: string;
}
interface DealerClaimHistoryAttributes {
historyId: string;
requestId: string;
approvalLevelId?: string;
levelNumber?: number;
levelName?: string;
version: number;
snapshotType: SnapshotType;
snapshotData: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | ApprovalSnapshotData | any;
changeReason?: string;
changedBy: string;
createdAt: Date;
}
interface DealerClaimHistoryCreationAttributes extends Optional<DealerClaimHistoryAttributes, 'historyId' | 'approvalLevelId' | 'levelNumber' | 'levelName' | 'changeReason' | 'createdAt'> { }
class DealerClaimHistory extends Model<DealerClaimHistoryAttributes, DealerClaimHistoryCreationAttributes> implements DealerClaimHistoryAttributes {
public historyId!: string;
public requestId!: string;
public approvalLevelId?: string;
public levelNumber?: number;
public version!: number;
public snapshotType!: SnapshotType;
public snapshotData!: ProposalSnapshotData | CompletionSnapshotData | IOSnapshotData | WorkflowSnapshotData | any;
public changeReason?: string;
public changedBy!: string;
public createdAt!: Date;
}
DealerClaimHistory.init(
{
historyId: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
field: 'history_id'
},
requestId: {
type: DataTypes.UUID,
allowNull: false,
field: 'request_id',
references: {
model: 'workflow_requests',
key: 'request_id'
}
},
approvalLevelId: {
type: DataTypes.UUID,
allowNull: true,
field: 'approval_level_id',
references: {
model: 'approval_levels',
key: 'level_id'
}
},
levelNumber: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'level_number'
},
levelName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'level_name'
},
version: {
type: DataTypes.INTEGER,
allowNull: false
},
snapshotType: {
type: DataTypes.ENUM('PROPOSAL', 'COMPLETION', 'INTERNAL_ORDER', 'WORKFLOW', 'APPROVE'),
allowNull: false,
field: 'snapshot_type'
},
snapshotData: {
type: DataTypes.JSONB,
allowNull: false,
field: 'snapshot_data'
},
changeReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'change_reason'
},
changedBy: {
type: DataTypes.UUID,
allowNull: false,
field: 'changed_by',
references: {
model: 'users',
key: 'user_id'
}
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at'
}
},
{
sequelize,
modelName: 'DealerClaimHistory',
tableName: 'dealer_claim_history',
timestamps: false,
indexes: [
{
fields: ['request_id', 'level_number', 'version'],
name: 'idx_history_request_level_version'
},
{
fields: ['approval_level_id', 'version'],
name: 'idx_history_level_version'
},
{
fields: ['request_id', 'snapshot_type'],
name: 'idx_history_request_type'
},
{
fields: ['snapshot_type', 'level_number'],
name: 'idx_history_type_level'
}
]
}
);
DealerClaimHistory.belongsTo(WorkflowRequest, { foreignKey: 'requestId' });
DealerClaimHistory.belongsTo(ApprovalLevel, { foreignKey: 'approvalLevelId' });
DealerClaimHistory.belongsTo(User, { as: 'changer', foreignKey: 'changedBy' });
export { DealerClaimHistory };

View File

@ -33,7 +33,7 @@ interface WorkflowRequestAttributes {
updatedAt: Date; updatedAt: Date;
} }
interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'createdAt' | 'updatedAt'> { } interface WorkflowRequestCreationAttributes extends Optional<WorkflowRequestAttributes, 'requestId' | 'submissionDate' | 'closureDate' | 'conclusionRemark' | 'aiGeneratedConclusion' | 'isPaused' | 'pausedAt' | 'pausedBy' | 'pauseReason' | 'pauseResumeDate' | 'pauseTatSnapshot' | 'createdAt' | 'updatedAt'> {}
class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCreationAttributes> implements WorkflowRequestAttributes { class WorkflowRequest extends Model<WorkflowRequestAttributes, WorkflowRequestCreationAttributes> implements WorkflowRequestAttributes {
public requestId!: string; public requestId!: string;

View File

@ -1,177 +1,180 @@
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
import { sequelize } from '../config/database'; import { sequelize } from '@config/database';
import { User } from './User'; import { User } from './User';
interface WorkflowTemplateAttributes { interface WorkflowTemplateAttributes {
templateId: string; templateId: string;
templateName: string; templateName: string;
templateCode?: string; templateCode?: string;
templateDescription?: string; templateDescription?: string;
templateCategory?: string; templateCategory?: string;
workflowType?: string; workflowType?: string;
approvalLevelsConfig?: any; approvalLevelsConfig?: any;
defaultTatHours?: number; defaultTatHours?: number;
formStepsConfig?: any; formStepsConfig?: any;
userFieldMappings?: any; userFieldMappings?: any;
dynamicApproverConfig?: any; dynamicApproverConfig?: any;
isActive: boolean; isActive: boolean;
isSystemTemplate: boolean; isSystemTemplate: boolean;
usageCount: number; usageCount: number;
createdBy?: string; createdBy?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface WorkflowTemplateCreationAttributes extends Optional<WorkflowTemplateAttributes, 'templateId' | 'templateCode' | 'templateDescription' | 'templateCategory' | 'workflowType' | 'approvalLevelsConfig' | 'defaultTatHours' | 'formStepsConfig' | 'userFieldMappings' | 'dynamicApproverConfig' | 'createdBy' | 'createdAt' | 'updatedAt'> { } interface WorkflowTemplateCreationAttributes extends Optional<WorkflowTemplateAttributes, 'templateId' | 'templateCode' | 'templateDescription' | 'templateCategory' | 'workflowType' | 'approvalLevelsConfig' | 'defaultTatHours' | 'formStepsConfig' | 'userFieldMappings' | 'dynamicApproverConfig' | 'createdBy' | 'createdAt' | 'updatedAt'> {}
export class WorkflowTemplate extends Model<WorkflowTemplateAttributes, WorkflowTemplateCreationAttributes> implements WorkflowTemplateAttributes { class WorkflowTemplate extends Model<WorkflowTemplateAttributes, WorkflowTemplateCreationAttributes> implements WorkflowTemplateAttributes {
public templateId!: string; public templateId!: string;
public templateName!: string; public templateName!: string;
public templateCode?: string; public templateCode?: string;
public templateDescription?: string; public templateDescription?: string;
public templateCategory?: string; public templateCategory?: string;
public workflowType?: string; public workflowType?: string;
public approvalLevelsConfig?: any; public approvalLevelsConfig?: any;
public defaultTatHours?: number; public defaultTatHours?: number;
public formStepsConfig?: any; public formStepsConfig?: any;
public userFieldMappings?: any; public userFieldMappings?: any;
public dynamicApproverConfig?: any; public dynamicApproverConfig?: any;
public isActive!: boolean; public isActive!: boolean;
public isSystemTemplate!: boolean; public isSystemTemplate!: boolean;
public usageCount!: number; public usageCount!: number;
public createdBy?: string; public createdBy?: string;
public createdAt!: Date; public createdAt!: Date;
public updatedAt!: Date; public updatedAt!: Date;
// Associations // Associations
public creator?: User; public creator?: User;
} }
WorkflowTemplate.init( WorkflowTemplate.init(
{ {
templateId: { templateId: {
type: DataTypes.UUID, type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
field: 'template_id' 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'
}
}, },
{ templateName: {
sequelize, type: DataTypes.STRING(200),
modelName: 'WorkflowTemplate', allowNull: false,
tableName: 'workflow_templates', field: 'template_name'
timestamps: true, },
createdAt: 'created_at', templateCode: {
updatedAt: 'updated_at', type: DataTypes.STRING(50),
indexes: [ allowNull: true,
{ unique: true,
unique: true, field: 'template_code'
fields: ['template_code'] },
}, templateDescription: {
{ type: DataTypes.TEXT,
fields: ['workflow_type'] allowNull: true,
}, field: 'template_description'
{ },
fields: ['is_active'] 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 // Associations
WorkflowTemplate.belongsTo(User, { WorkflowTemplate.belongsTo(User, {
as: 'creator', as: 'creator',
foreignKey: 'createdBy', foreignKey: 'createdBy',
targetKey: 'userId' targetKey: 'userId'
}); });
export { WorkflowTemplate };

View File

@ -20,12 +20,9 @@ import { DealerClaimDetails } from './DealerClaimDetails';
import { DealerProposalDetails } from './DealerProposalDetails'; import { DealerProposalDetails } from './DealerProposalDetails';
import { DealerCompletionDetails } from './DealerCompletionDetails'; import { DealerCompletionDetails } from './DealerCompletionDetails';
import { DealerProposalCostItem } from './DealerProposalCostItem'; import { DealerProposalCostItem } from './DealerProposalCostItem';
import { WorkflowTemplate } from './WorkflowTemplate';
import { InternalOrder } from './InternalOrder'; import { InternalOrder } from './InternalOrder';
import { ClaimBudgetTracking } from './ClaimBudgetTracking'; import { ClaimBudgetTracking } from './ClaimBudgetTracking';
import { Dealer } from './Dealer';
import { ActivityType } from './ActivityType';
import { DealerClaimHistory } from './DealerClaimHistory';
import { WorkflowTemplate } from './WorkflowTemplate';
// Define associations // Define associations
const defineAssociations = () => { const defineAssociations = () => {
@ -138,13 +135,6 @@ const defineAssociations = () => {
sourceKey: 'requestId' sourceKey: 'requestId'
}); });
// DealerClaimHistory associations
WorkflowRequest.hasMany(DealerClaimHistory, {
as: 'history',
foreignKey: 'requestId',
sourceKey: 'requestId'
});
// Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts // Note: belongsTo associations are defined in individual model files to avoid duplicate alias conflicts
// Only hasMany associations from WorkflowRequest are defined here since they're one-way // Only hasMany associations from WorkflowRequest are defined here since they're one-way
}; };
@ -170,16 +160,13 @@ export {
ConclusionRemark, ConclusionRemark,
RequestSummary, RequestSummary,
SharedSummary, SharedSummary,
WorkflowTemplate,
DealerClaimDetails, DealerClaimDetails,
DealerProposalDetails, DealerProposalDetails,
DealerCompletionDetails, DealerCompletionDetails,
DealerProposalCostItem, DealerProposalCostItem,
WorkflowTemplate,
InternalOrder, InternalOrder,
ClaimBudgetTracking, ClaimBudgetTracking
Dealer,
ActivityType,
DealerClaimHistory
}; };
// Export default sequelize instance // Export default sequelize instance

View File

@ -210,13 +210,6 @@ export async function handleTatJob(job: Job<TatJobData>) {
type === 'threshold2' ? 'HIGH' : type === 'threshold2' ? 'HIGH' :
'MEDIUM'; 'MEDIUM';
// Format time remaining/overdue for email
const timeRemainingText = remainingHours > 0
? `${remainingHours.toFixed(1)} hours remaining`
: type === 'breach'
? `${Math.abs(remainingHours).toFixed(1)} hours overdue`
: 'Time exceeded';
// Send notification to approver (with error handling to prevent job failure) // Send notification to approver (with error handling to prevent job failure)
try { try {
await notificationService.sendToUsers([approverId], { await notificationService.sendToUsers([approverId], {
@ -227,17 +220,7 @@ export async function handleTatJob(job: Job<TatJobData>) {
url: `/request/${requestNumber}`, url: `/request/${requestNumber}`,
type: type, type: type,
priority: notificationPriority, priority: notificationPriority,
actionRequired: type === 'breach' || type === 'threshold2', // Require action for critical alerts actionRequired: type === 'breach' || type === 'threshold2' // Require action for critical alerts
metadata: {
thresholdPercentage: thresholdPercentage,
tatInfo: {
thresholdPercentage: thresholdPercentage,
timeRemaining: timeRemainingText,
tatDeadline: expectedCompletionTime,
assignedDate: levelStartTime,
timeOverdue: type === 'breach' ? timeRemainingText : undefined
}
}
}); });
logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`); logger.info(`[TAT Processor] ✅ Notification sent to approver ${approverId} for ${type} (${threshold}%)`);
} catch (notificationError: any) { } catch (notificationError: any) {

View File

@ -14,12 +14,7 @@ import {
updateUserRole, updateUserRole,
getUsersByRole, getUsersByRole,
getRoleStatistics, getRoleStatistics,
assignRoleByEmail, assignRoleByEmail
getAllActivityTypes,
getActivityTypeById,
createActivityType,
updateActivityType,
deleteActivityType
} from '@controllers/admin.controller'; } from '@controllers/admin.controller';
const router = Router(); const router = Router();
@ -140,48 +135,5 @@ router.get('/users/by-role', getUsersByRole);
*/ */
router.get('/users/role-statistics', getRoleStatistics); router.get('/users/role-statistics', getRoleStatistics);
// ==================== Activity Type Management Routes ====================
/**
* @route GET /api/admin/activity-types
* @desc Get all activity types (optional activeOnly filter)
* @query activeOnly (optional): true | false
* @access Admin
*/
router.get('/activity-types', getAllActivityTypes);
/**
* @route GET /api/admin/activity-types/:activityTypeId
* @desc Get a single activity type by ID
* @params activityTypeId
* @access Admin
*/
router.get('/activity-types/:activityTypeId', getActivityTypeById);
/**
* @route POST /api/admin/activity-types
* @desc Create a new activity type
* @body { title, itemCode?, taxationType?, sapRefNo? }
* @access Admin
*/
router.post('/activity-types', createActivityType);
/**
* @route PUT /api/admin/activity-types/:activityTypeId
* @desc Update an activity type
* @params activityTypeId
* @body Activity type fields to update
* @access Admin
*/
router.put('/activity-types/:activityTypeId', updateActivityType);
/**
* @route DELETE /api/admin/activity-types/:activityTypeId
* @desc Delete (deactivate) an activity type
* @params activityTypeId
* @access Admin
*/
router.delete('/activity-types/:activityTypeId', deleteActivityType);
export default router; export default router;

View File

@ -20,18 +20,6 @@ router.post('/token-exchange',
asyncHandler(authController.exchangeToken.bind(authController)) asyncHandler(authController.exchangeToken.bind(authController))
); );
// Tanflow token exchange endpoint (no authentication required)
router.post('/tanflow/token-exchange',
validateBody(tokenExchangeSchema),
asyncHandler(authController.exchangeTanflowToken.bind(authController))
);
// Tanflow token refresh endpoint (no authentication required)
router.post('/tanflow/refresh',
validateBody(refreshTokenSchema),
asyncHandler(authController.refreshTanflowToken.bind(authController))
);
// SSO callback endpoint (no authentication required) // SSO callback endpoint (no authentication required)
router.post('/sso-callback', router.post('/sso-callback',
validateBody(ssoCallbackSchema), validateBody(ssoCallbackSchema),

View File

@ -1,7 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { getPublicConfig } from '../config/system.config'; import { getPublicConfig } from '../config/system.config';
import { asyncHandler } from '../middlewares/errorHandler.middleware'; import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { activityTypeService } from '../services/activityType.service';
const router = Router(); const router = Router();
@ -21,27 +20,5 @@ router.get('/',
}) })
); );
/**
* GET /api/v1/config/activity-types
* Returns all active activity types for frontend
* No authentication required - public endpoint
*/
router.get('/activity-types',
asyncHandler(async (req: Request, res: Response): Promise<void> => {
const activityTypes = await activityTypeService.getAllActivityTypes(true);
res.json({
success: true,
data: activityTypes.map((at: any) => ({
activityTypeId: at.activityTypeId,
title: at.title,
itemCode: at.itemCode,
taxationType: at.taxationType,
sapRefNo: at.sapRefNo
}))
});
return;
})
);
export default router; export default router;

View File

@ -34,12 +34,5 @@ router.get('/code/:dealerCode', authenticateToken, asyncHandler(dealerController
*/ */
router.get('/email/:email', authenticateToken, asyncHandler(dealerController.getDealerByEmail.bind(dealerController))); router.get('/email/:email', authenticateToken, asyncHandler(dealerController.getDealerByEmail.bind(dealerController)));
/**
* @route GET /api/v1/dealers/verify/:dealerCode
* @desc Verify dealer is logged in to the system
* @access Private
*/
router.get('/verify/:dealerCode', authenticateToken, asyncHandler(dealerController.verifyDealerLogin.bind(dealerController)));
export default router; export default router;

View File

@ -1,6 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { DealerClaimController } from '../controllers/dealerClaim.controller'; import { DealerClaimController } from '../controllers/dealerClaim.controller';
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
import { authenticateToken } from '../middlewares/auth.middleware'; import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware'; import { asyncHandler } from '../middlewares/errorHandler.middleware';
import multer from 'multer'; import multer from 'multer';
@ -8,7 +7,6 @@ import path from 'path';
const router = Router(); const router = Router();
const dealerClaimController = new DealerClaimController(); const dealerClaimController = new DealerClaimController();
const dealerDashboardController = new DealerDashboardController();
// Configure multer for file uploads (memory storage for direct GCS upload) // Configure multer for file uploads (memory storage for direct GCS upload)
const upload = multer({ const upload = multer({
@ -27,13 +25,6 @@ const upload = multer({
}, },
}); });
/**
* @route GET /api/v1/dealer-claims/dashboard
* @desc Get dealer dashboard KPIs and category data
* @access Private
*/
router.get('/dashboard', authenticateToken, asyncHandler(dealerDashboardController.getDashboard.bind(dealerDashboardController)));
/** /**
* @route POST /api/v1/dealer-claims * @route POST /api/v1/dealer-claims
* @desc Create a new dealer claim request * @desc Create a new dealer claim request
@ -102,13 +93,5 @@ router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClai
*/ */
router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController))); router.post('/:requestId/credit-note/send', authenticateToken, asyncHandler(dealerClaimController.sendCreditNoteToDealer.bind(dealerClaimController)));
/**
* @route POST /api/v1/dealer-claims/test/sap-block
* @desc Test SAP budget blocking directly (for testing/debugging)
* @access Private
* @body { ioNumber: string, amount: number, requestNumber?: string }
*/
router.post('/test/sap-block', authenticateToken, asyncHandler(dealerClaimController.testSapBudgetBlock.bind(dealerClaimController)));
export default router; export default router;

View File

@ -21,33 +21,6 @@ import { pauseController } from '../controllers/pause.controller';
import logger from '@utils/logger'; import logger from '@utils/logger';
const router = Router(); const router = Router();
/**
* Helper function to create proper Content-Disposition header
* Returns clean filename header that browsers handle correctly
*/
function createContentDisposition(disposition: 'inline' | 'attachment', filename: string): string {
// Clean filename: only remove truly problematic characters for HTTP headers
// Keep spaces, dots, hyphens, underscores - these are safe
const cleanFilename = filename
.replace(/[<>:"|?*\x00-\x1F\x7F]/g, '_') // Only replace truly problematic chars
.replace(/\\/g, '_') // Replace backslashes
.trim();
// For ASCII-only filenames, use simple format (browsers prefer this)
// Only use filename* for non-ASCII characters
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
if (hasNonASCII) {
// Use RFC 5987 encoding for non-ASCII characters
const encodedFilename = encodeURIComponent(filename);
return `${disposition}; filename="${cleanFilename}"; filename*=UTF-8''${encodedFilename}`;
} else {
// Simple ASCII filename - use clean version (no filename* needed)
// This prevents browsers from showing both filename and filename*
return `${disposition}; filename="${cleanFilename}"`;
}
}
const workflowController = new WorkflowController(); const workflowController = new WorkflowController();
const approvalController = new ApprovalController(); const approvalController = new ApprovalController();
const workNoteController = new WorkNoteController(); const workNoteController = new WorkNoteController();
@ -250,125 +223,10 @@ router.get('/documents/:documentId/preview',
return; return;
} }
// If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS
if (!storageUrl && filePath && filePath.startsWith('requests/')) {
try {
// Use the existing GCS storage service instance
if (!gcsStorageService.isConfigured()) {
throw new Error('GCS not configured');
}
// Access the storage instance from the service
const { Storage } = require('@google-cloud/storage');
const keyFilePath = process.env.GCP_KEY_FILE || '';
const bucketName = process.env.GCP_BUCKET_NAME || '';
const path = require('path');
const resolvedKeyPath = path.isAbsolute(keyFilePath)
? keyFilePath
: path.resolve(process.cwd(), keyFilePath);
const storage = new Storage({
projectId: process.env.GCP_PROJECT_ID || '',
keyFilename: resolvedKeyPath,
});
const bucket = storage.bucket(bucketName);
const file = bucket.file(filePath);
// Check if file exists
const [exists] = await file.exists();
if (!exists) {
res.status(404).json({ success: false, error: 'File not found in GCS' });
return;
}
// Get file metadata for content type
const [metadata] = await file.getMetadata();
const contentType = metadata.contentType || fileType || 'application/octet-stream';
// Set CORS headers
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
res.setHeader('Content-Type', contentType);
// For images and PDFs, allow inline viewing
const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf'));
const disposition = isPreviewable ? 'inline' : 'attachment';
res.setHeader('Content-Disposition', createContentDisposition(disposition, fileName));
// Stream file from GCS to response
file.createReadStream()
.on('error', (streamError: Error) => {
const logger = require('../utils/logger').default;
logger.error('[Workflow] Failed to stream file from GCS', {
documentId,
filePath,
error: streamError.message,
});
if (!res.headersSent) {
res.status(500).json({
success: false,
error: 'Failed to stream file from storage'
});
}
})
.pipe(res);
return;
} catch (gcsError) {
const logger = require('../utils/logger').default;
logger.error('[Workflow] Failed to access GCS file for preview', {
documentId,
filePath,
error: gcsError instanceof Error ? gcsError.message : 'Unknown error',
});
res.status(500).json({
success: false,
error: 'Failed to access file. Please try again.'
});
return;
}
}
// Local file handling - check if storageUrl is a local path (starts with /uploads/) // Local file handling - check if storageUrl is a local path (starts with /uploads/)
if (storageUrl && storageUrl.startsWith('/uploads/')) { if (storageUrl && storageUrl.startsWith('/uploads/')) {
// Extract relative path from storageUrl (remove /uploads/ prefix) // File is served by express.static middleware, redirect to the storage URL
const relativePath = storageUrl.replace(/^\/uploads\//, ''); res.redirect(storageUrl);
const absolutePath = path.join(UPLOAD_DIR, relativePath);
// Check if file exists
if (!fs.existsSync(absolutePath)) {
res.status(404).json({ success: false, error: 'File not found on server' });
return;
}
// Set CORS headers to allow blob URL creation when served from same origin
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
// Set appropriate content type
res.contentType(fileType || 'application/octet-stream');
// For images and PDFs, allow inline viewing
const isPreviewable = fileType && (fileType.includes('image') || fileType.includes('pdf'));
if (isPreviewable) {
res.setHeader('Content-Disposition', `inline; filename="${fileName}"`);
} else {
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
}
res.sendFile(absolutePath, (err) => {
if (err && !res.headersSent) {
res.status(500).json({ success: false, error: 'Failed to serve file' });
}
});
return; return;
} }
@ -438,122 +296,17 @@ router.get('/documents/:documentId/download',
return; return;
} }
// If storageUrl is null but filePath indicates GCS storage, stream file directly from GCS
if (!storageUrl && filePath && filePath.startsWith('requests/')) {
try {
// Use the existing GCS storage service instance
if (!gcsStorageService.isConfigured()) {
throw new Error('GCS not configured');
}
// Access the storage instance from the service
const { Storage } = require('@google-cloud/storage');
const keyFilePath = process.env.GCP_KEY_FILE || '';
const bucketName = process.env.GCP_BUCKET_NAME || '';
const path = require('path');
const resolvedKeyPath = path.isAbsolute(keyFilePath)
? keyFilePath
: path.resolve(process.cwd(), keyFilePath);
const storage = new Storage({
projectId: process.env.GCP_PROJECT_ID || '',
keyFilename: resolvedKeyPath,
});
const bucket = storage.bucket(bucketName);
const file = bucket.file(filePath);
// Check if file exists
const [exists] = await file.exists();
if (!exists) {
res.status(404).json({ success: false, error: 'File not found in GCS' });
return;
}
// Get file metadata for content type
const [metadata] = await file.getMetadata();
const contentType = metadata.contentType || (document as any).mimeType || (document as any).mime_type || 'application/octet-stream';
// Set CORS headers
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
// Set headers for download
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName));
// Stream file from GCS to response
file.createReadStream()
.on('error', (streamError: Error) => {
const logger = require('../utils/logger').default;
logger.error('[Workflow] Failed to stream file from GCS for download', {
documentId,
filePath,
error: streamError.message,
});
if (!res.headersSent) {
res.status(500).json({
success: false,
error: 'Failed to stream file from storage'
});
}
})
.pipe(res);
return;
} catch (gcsError) {
const logger = require('../utils/logger').default;
logger.error('[Workflow] Failed to access GCS file for download', {
documentId,
filePath,
error: gcsError instanceof Error ? gcsError.message : 'Unknown error',
});
res.status(500).json({
success: false,
error: 'Failed to access file. Please try again.'
});
return;
}
}
// Local file handling - check if storageUrl is a local path (starts with /uploads/) // Local file handling - check if storageUrl is a local path (starts with /uploads/)
if (storageUrl && storageUrl.startsWith('/uploads/')) { if (storageUrl && storageUrl.startsWith('/uploads/')) {
// Extract relative path from storageUrl (remove /uploads/ prefix) // File is served by express.static middleware, redirect to the storage URL
const relativePath = storageUrl.replace(/^\/uploads\//, ''); res.redirect(storageUrl);
const absolutePath = path.join(UPLOAD_DIR, relativePath);
// Check if file exists
if (!fs.existsSync(absolutePath)) {
res.status(404).json({ success: false, error: 'File not found on server' });
return;
}
// Set CORS headers
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
// Set headers for download
const fileTypeForDownload = (document as any).mimeType || (document as any).mime_type || 'application/octet-stream';
res.setHeader('Content-Type', fileTypeForDownload);
res.setHeader('Content-Disposition', createContentDisposition('attachment', fileName));
res.download(absolutePath, fileName, (err) => {
if (err && !res.headersSent) {
res.status(500).json({ success: false, error: 'Failed to download file' });
}
});
return; return;
} }
// Legacy local file handling (absolute path stored in filePath) // Legacy local file handling (absolute path stored in filePath)
// Resolve relative path if needed // Resolve relative path if needed
const path = require('path');
const { UPLOAD_DIR } = require('../config/storage');
const absolutePath = filePath && !path.isAbsolute(filePath) const absolutePath = filePath && !path.isAbsolute(filePath)
? path.join(UPLOAD_DIR, filePath) ? path.join(UPLOAD_DIR, filePath)
: filePath; : filePath;
@ -588,45 +341,15 @@ router.get('/work-notes/attachments/:attachmentId/preview',
// Local file handling - check if storageUrl is a local path (starts with /uploads/) // Local file handling - check if storageUrl is a local path (starts with /uploads/)
if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) { if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) {
// Extract relative path from storageUrl (remove /uploads/ prefix) // File is served by express.static middleware, redirect to the storage URL
const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, ''); res.redirect(fileInfo.storageUrl);
const absolutePath = path.join(UPLOAD_DIR, relativePath);
// Check if file exists
if (!fs.existsSync(absolutePath)) {
res.status(404).json({ success: false, error: 'File not found' });
return;
}
// Set CORS headers to allow blob URL creation when served from same origin
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
// Set appropriate content type
res.contentType(fileInfo.fileType || 'application/octet-stream');
// For images and PDFs, allow inline viewing
const isPreviewable = fileInfo.fileType && (fileInfo.fileType.includes('image') || fileInfo.fileType.includes('pdf'));
if (isPreviewable) {
res.setHeader('Content-Disposition', `inline; filename="${fileInfo.fileName}"`);
} else {
res.setHeader('Content-Disposition', `attachment; filename="${fileInfo.fileName}"`);
}
res.sendFile(absolutePath, (err) => {
if (err && !res.headersSent) {
res.status(500).json({ success: false, error: 'Failed to serve file' });
}
});
return; return;
} }
// Legacy local file handling (absolute path stored in filePath) // Legacy local file handling (absolute path stored in filePath)
// Resolve relative path if needed // Resolve relative path if needed
const path = require('path');
const { UPLOAD_DIR } = require('../config/storage');
const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath)
? path.join(UPLOAD_DIR, fileInfo.filePath) ? path.join(UPLOAD_DIR, fileInfo.filePath)
: fileInfo.filePath; : fileInfo.filePath;
@ -680,34 +403,15 @@ router.get('/work-notes/attachments/:attachmentId/download',
// Local file handling - check if storageUrl is a local path (starts with /uploads/) // Local file handling - check if storageUrl is a local path (starts with /uploads/)
if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) { if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) {
// Extract relative path from storageUrl (remove /uploads/ prefix) // File is served by express.static middleware, redirect to the storage URL
const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, ''); res.redirect(fileInfo.storageUrl);
const absolutePath = path.join(UPLOAD_DIR, relativePath);
// Check if file exists
if (!fs.existsSync(absolutePath)) {
res.status(404).json({ success: false, error: 'File not found' });
return;
}
// Set CORS headers
const origin = req.headers.origin;
if (origin) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
res.setHeader('Access-Control-Expose-Headers', 'Content-Type, Content-Disposition');
res.download(absolutePath, fileInfo.fileName, (err) => {
if (err && !res.headersSent) {
res.status(500).json({ success: false, error: 'Failed to download file' });
}
});
return; return;
} }
// Legacy local file handling (absolute path stored in filePath) // Legacy local file handling (absolute path stored in filePath)
// Resolve relative path if needed // Resolve relative path if needed
const path = require('path');
const { UPLOAD_DIR } = require('../config/storage');
const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath) const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath)
? path.join(UPLOAD_DIR, fileInfo.filePath) ? path.join(UPLOAD_DIR, fileInfo.filePath)
: fileInfo.filePath; : fileInfo.filePath;
@ -874,19 +578,4 @@ router.get('/:id/pause',
asyncHandler(pauseController.getPauseDetails.bind(pauseController)) asyncHandler(pauseController.getPauseDetails.bind(pauseController))
); );
// Initiator actions for rejected/returned requests
router.post('/:id/initiator-action',
authenticateToken,
requireParticipantTypes(['INITIATOR']),
validateParams(workflowParamsSchema),
asyncHandler(workflowController.handleInitiatorAction.bind(workflowController))
);
// Get revision history
router.get('/:id/history',
authenticateToken,
validateParams(workflowParamsSchema),
asyncHandler(workflowController.getHistory.bind(workflowController))
);
export default router; export default router;

View File

@ -1,16 +0,0 @@
import { Router } from 'express';
import { createTemplate, getTemplates, updateTemplate, deleteTemplate } from '../controllers/workflowTemplate.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { requireAdmin } from '../middlewares/authorization.middleware';
const router = Router();
// Public route to get templates (authenticated users)
router.get('/', authenticateToken, getTemplates);
// Admin only route to create templates
router.post('/', authenticateToken, requireAdmin, createTemplate);
router.put('/:id', authenticateToken, requireAdmin, updateTemplate);
router.delete('/:id', authenticateToken, requireAdmin, deleteTemplate);
export default router;

View File

@ -11,8 +11,8 @@
*/ */
import { Client } from 'pg'; import { Client } from 'pg';
import { sequelize } from '../config/database';
import { QueryTypes } from 'sequelize'; import { QueryTypes } from 'sequelize';
import { initializeGoogleSecretManager } from '../services/googleSecretManager.service';
import { exec } from 'child_process'; import { exec } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
@ -21,15 +21,14 @@ import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '../../.env') }); dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const execAsync = promisify(exec); const execAsync = promisify(exec);
// DB constants moved inside functions to ensure secrets are loaded first
const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = parseInt(process.env.DB_PORT || '5432');
const DB_USER = process.env.DB_USER || 'postgres';
const DB_PASSWORD = process.env.DB_PASSWORD || '';
const DB_NAME = process.env.DB_NAME || 'royal_enfield_workflow';
async function checkAndCreateDatabase(): Promise<boolean> { async function checkAndCreateDatabase(): Promise<boolean> {
const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = parseInt(process.env.DB_PORT || '5432');
const DB_USER = process.env.DB_USER || 'postgres';
const DB_PASSWORD = process.env.DB_PASSWORD || '';
const DB_NAME = process.env.DB_NAME || 'royal_enfield_workflow';
const client = new Client({ const client = new Client({
host: DB_HOST, host: DB_HOST,
port: DB_PORT, port: DB_PORT,
@ -134,10 +133,6 @@ async function runMigrations(): Promise<void> {
const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables'); const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables');
const m39 = require('../migrations/20251214-create-dealer-completion-expenses'); const m39 = require('../migrations/20251214-create-dealer-completion-expenses');
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns'); const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
const m41 = require('../migrations/20250120-create-dealers-table');
const m42 = require('../migrations/20250125-create-activity-types');
const m43 = require('../migrations/20260113-redesign-dealer-claim-history');
const m44 = require('../migrations/20260123-fix-template-id-schema');
const migrations = [ const migrations = [
{ name: '2025103000-create-users', module: m0 }, { name: '2025103000-create-users', module: m0 },
@ -183,14 +178,8 @@ async function runMigrations(): Promise<void> {
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 }, { name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 }, { name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
{ name: '20260123-fix-template-id-schema', module: m44 },
]; ];
// Dynamically import sequelize after secrets are loaded
const { sequelize } = require('../config/database');
const queryInterface = sequelize.getQueryInterface(); const queryInterface = sequelize.getQueryInterface();
// Ensure migrations tracking table exists // Ensure migrations tracking table exists
@ -206,10 +195,10 @@ async function runMigrations(): Promise<void> {
} }
// Get already executed migrations // Get already executed migrations
const executedResults = await sequelize.query( const executedResults = await sequelize.query<{ name: string }>(
'SELECT name FROM migrations ORDER BY id', 'SELECT name FROM migrations ORDER BY id',
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
) as { name: string }[]; );
const executedMigrations = executedResults.map(r => r.name); const executedMigrations = executedResults.map(r => r.name);
// Find pending migrations // Find pending migrations
@ -257,7 +246,6 @@ async function runMigrations(): Promise<void> {
async function testConnection(): Promise<void> { async function testConnection(): Promise<void> {
try { try {
console.log('🔌 Testing database connection...'); console.log('🔌 Testing database connection...');
const { sequelize } = require('../config/database');
await sequelize.authenticate(); await sequelize.authenticate();
console.log('✅ Database connection established!'); console.log('✅ Database connection established!');
} catch (error: any) { } catch (error: any) {
@ -272,10 +260,6 @@ async function autoSetup(): Promise<void> {
console.log('========================================\n'); console.log('========================================\n');
try { try {
// Step 0: Initialize secrets
console.log('🔐 Initializing secrets...');
await initializeGoogleSecretManager();
// Step 1: Check and create database if needed // Step 1: Check and create database if needed
const wasCreated = await checkAndCreateDatabase(); const wasCreated = await checkAndCreateDatabase();
@ -289,10 +273,6 @@ async function autoSetup(): Promise<void> {
console.log('✅ Setup completed successfully!'); console.log('✅ Setup completed successfully!');
console.log('========================================\n'); console.log('========================================\n');
console.log('📝 Note: Admin configurations will be auto-seeded on server start if table is empty.');
console.log('📝 Note: Dealers table will be empty - import dealers using CSV import script.\n');
console.log('📝 Note: Admin configurations will be auto-seeded on server start if table is empty.\n'); console.log('📝 Note: Admin configurations will be auto-seeded on server start if table is empty.\n');
if (wasCreated) { if (wasCreated) {

View File

@ -1,19 +0,0 @@
import { sequelize } from '../config/database';
async function run() {
try {
await sequelize.authenticate();
console.log('✅ Connection established');
const tableDescription = await sequelize.getQueryInterface().describeTable('workflow_templates');
console.log('Current schema for workflow_templates:', JSON.stringify(tableDescription, null, 2));
} catch (error: any) {
console.error('❌ Error:', error.message);
} finally {
await sequelize.close();
}
}
run();

View File

@ -1,31 +0,0 @@
import { sequelize } from '../config/database';
import { up } from '../migrations/20260123-fix-template-id-schema';
async function forceRun() {
try {
await sequelize.authenticate();
console.log('✅ Connected to DB');
const queryInterface = sequelize.getQueryInterface();
// 1. Remove from migrations table if exists (to keep track clean)
await sequelize.query("DELETE FROM migrations WHERE name = '20260123-fix-template-id-schema'");
console.log('DATA CLEANUP: Removed migration record to force re-run tracking.');
// 2. Run the migration up function directly
console.log('🚀 Running migration manually...');
await up(queryInterface);
// 3. Mark as executed
await sequelize.query("INSERT INTO migrations (name) VALUES ('20260123-fix-template-id-schema')");
console.log('✅ Migration applied and tracked successfully.');
} catch (error: any) {
console.error('❌ Error executing force migration:', error.message, error);
} finally {
await sequelize.close();
}
}
forceRun();

View File

@ -1,5 +1,5 @@
import { sequelize } from '../config/database';
import { QueryInterface, QueryTypes } from 'sequelize'; import { QueryInterface, QueryTypes } from 'sequelize';
import { initializeGoogleSecretManager } from '../services/googleSecretManager.service';
import * as m0 from '../migrations/2025103000-create-users'; import * as m0 from '../migrations/2025103000-create-users';
import * as m1 from '../migrations/2025103001-create-workflow-requests'; import * as m1 from '../migrations/2025103001-create-workflow-requests';
import * as m2 from '../migrations/2025103002-create-approval-levels'; import * as m2 from '../migrations/2025103002-create-approval-levels';
@ -43,10 +43,6 @@ import * as m37 from '../migrations/20251213-drop-claim-details-invoice-columns'
import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables'; import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-tables';
import * as m39 from '../migrations/20251214-create-dealer-completion-expenses'; import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns'; import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
import * as m41 from '../migrations/20250120-create-dealers-table';
import * as m42 from '../migrations/20250125-create-activity-types';
import * as m43 from '../migrations/20260113-redesign-dealer-claim-history';
import * as m44 from '../migrations/20260123-fix-template-id-schema';
interface Migration { interface Migration {
name: string; name: string;
@ -104,10 +100,6 @@ const migrations: Migration[] = [
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 }, { name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
{ name: '20251214-create-dealer-completion-expenses', module: m39 }, { name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
{ name: '20260113-redesign-dealer-claim-history', module: m43 },
{ name: '20260123-fix-template-id-schema', module: m44 },
]; ];
/** /**
@ -136,12 +128,12 @@ async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<vo
/** /**
* Get list of already executed migrations * Get list of already executed migrations
*/ */
async function getExecutedMigrations(sequelize: any): Promise<string[]> { async function getExecutedMigrations(): Promise<string[]> {
try { try {
const results = await sequelize.query( const results = await sequelize.query<{ name: string }>(
'SELECT name FROM migrations ORDER BY id', 'SELECT name FROM migrations ORDER BY id',
{ type: QueryTypes.SELECT } { type: QueryTypes.SELECT }
) as { name: string }[]; );
return results.map(r => r.name); return results.map(r => r.name);
} catch (error) { } catch (error) {
// Table might not exist yet // Table might not exist yet
@ -152,7 +144,7 @@ async function getExecutedMigrations(sequelize: any): Promise<string[]> {
/** /**
* Mark migration as executed * Mark migration as executed
*/ */
async function markMigrationExecuted(sequelize: any, name: string): Promise<void> { async function markMigrationExecuted(name: string): Promise<void> {
await sequelize.query( await sequelize.query(
'INSERT INTO migrations (name) VALUES (:name) ON CONFLICT (name) DO NOTHING', 'INSERT INTO migrations (name) VALUES (:name) ON CONFLICT (name) DO NOTHING',
{ {
@ -167,12 +159,6 @@ async function markMigrationExecuted(sequelize: any, name: string): Promise<void
*/ */
async function run() { async function run() {
try { try {
console.log('🔐 Initializing secrets...');
await initializeGoogleSecretManager();
// Dynamically import sequelize after secrets are loaded
const { sequelize } = require('../config/database');
await sequelize.authenticate(); await sequelize.authenticate();
const queryInterface = sequelize.getQueryInterface(); const queryInterface = sequelize.getQueryInterface();
@ -181,7 +167,7 @@ async function run() {
await ensureMigrationsTable(queryInterface); await ensureMigrationsTable(queryInterface);
// Get already executed migrations // Get already executed migrations
const executedMigrations = await getExecutedMigrations(sequelize); const executedMigrations = await getExecutedMigrations();
// Find pending migrations // Find pending migrations
const pendingMigrations = migrations.filter( const pendingMigrations = migrations.filter(
@ -196,12 +182,11 @@ async function run() {
console.log(`🔄 Running ${pendingMigrations.length} migration(s)...`); console.log(`🔄 Running ${pendingMigrations.length} migration(s)...`);
// Run each pending migration // Run each pending migration
for (const migration of pendingMigrations) { for (const migration of pendingMigrations) {
try { try {
await migration.module.up(queryInterface); await migration.module.up(queryInterface);
await markMigrationExecuted(sequelize, migration.name); await markMigrationExecuted(migration.name);
console.log(`${migration.name}`); console.log(`${migration.name}`);
} catch (error: any) { } catch (error: any) {
console.error(`❌ Migration failed: ${migration.name} - ${error.message}`); console.error(`❌ Migration failed: ${migration.name} - ${error.message}`);

Some files were not shown because too many files have changed in this diff Show More