Compare commits
46 Commits
dealer_cla
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e03049a861 | |||
| de469ab2c6 | |||
| b11e542a59 | |||
| f456fb8af9 | |||
| 088ac173a7 | |||
| be220bbb0c | |||
| d1ae0ffaec | |||
| 9285c97d4b | |||
| e7e97f92e3 | |||
| ee56dc8386 | |||
| e3bda6df15 | |||
| f89514eb2b | |||
| 077fe78e8a | |||
| 743f90d1a9 | |||
| 5ee206e493 | |||
| 3732ce66e3 | |||
| 0890ffe74d | |||
| e875e7fe7a | |||
| 87273ae044 | |||
| 970e7fec98 | |||
| d9a0fee8ec | |||
| f3cc409d9a | |||
| 0742b101b3 | |||
| e21f42f794 | |||
| e997421414 | |||
| 4c9ddc0371 | |||
| 94bc21ba23 | |||
| 2d6729ea30 | |||
| a2013e27a5 | |||
| 8ce8b2a659 | |||
| b864b20bfa | |||
| 0fd522eaa7 | |||
| 4cf7288857 | |||
| f69814ce98 | |||
| b2ccfe8b4b | |||
| 855cfc5c38 | |||
| 47077552cf | |||
| db02d6eb01 | |||
| 90fe2c8e87 | |||
| 9c8a8512bc | |||
| 651576f51d | |||
| 3f0a83acce | |||
| 51fcedca1b | |||
| 2b0d13436d | |||
| 53302fea21 | |||
| 1db4947617 |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
build/assets/conclusionApi-CMghC3Jo.js
Normal file
2
build/assets/conclusionApi-CMghC3Jo.js
Normal file
@ -0,0 +1,2 @@
|
||||
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
|
||||
@ -1 +1 @@
|
||||
{"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"}
|
||||
{"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"}
|
||||
@ -1,2 +0,0 @@
|
||||
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
|
||||
75
build/assets/index-7JN9lLwu.js
Normal file
75
build/assets/index-7JN9lLwu.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-7JN9lLwu.js.map
Normal file
1
build/assets/index-7JN9lLwu.js.map
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-B-mLDzJe.css
Normal file
1
build/assets/index-B-mLDzJe.css
Normal file
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
BIN
build/assets/landing_page_image-ClTD-4qZ.jpg
Normal file
BIN
build/assets/landing_page_image-ClTD-4qZ.jpg
Normal file
Binary file not shown.
|
After 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
@ -1,2 +0,0 @@
|
||||
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
|
||||
@ -1 +0,0 @@
|
||||
{"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"}
|
||||
13
build/assets/router-vendor-B1UBYWWO.js
Normal file
13
build/assets/router-vendor-B1UBYWWO.js
Normal file
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
1
build/assets/ui-vendor-DbB0YGPu.js.map
Normal file
1
build/assets/ui-vendor-DbB0YGPu.js.map
Normal file
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
7
build/assets/utils-vendor-DNMmNUQL.js
Normal file
7
build/assets/utils-vendor-DNMmNUQL.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/utils-vendor-DNMmNUQL.js.map
Normal file
1
build/assets/utils-vendor-DNMmNUQL.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -52,15 +52,15 @@
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-DtEUJDeH.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DA0cB_hD.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-BPwaxA-i.js">
|
||||
<script type="module" crossorigin src="/assets/index-7JN9lLwu.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DIkYAdWy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DbB0YGPu.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/router-vendor-CRr9x_Jp.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-P-Le9vHs.css">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B1UBYWWO.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B-mLDzJe.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -15,15 +15,16 @@
|
||||
|
||||
## 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 AI providers (Claude, OpenAI, or 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 **Google Cloud Vertex AI Gemini** to analyze the entire request lifecycle and create a comprehensive summary suitable for permanent archiving.
|
||||
|
||||
### Key Features
|
||||
- **Multi-Provider Support**: Supports Claude (Anthropic), OpenAI (GPT-4), and Google Gemini
|
||||
- **Vertex AI Integration**: Uses Google Cloud Vertex AI Gemini with service account authentication
|
||||
- **Context-Aware**: Analyzes approval flow, work notes, documents, and activities
|
||||
- **Configurable**: Admin-configurable max length, provider selection, and enable/disable
|
||||
- **Configurable**: Admin-configurable max length, model selection, and enable/disable
|
||||
- **Automatic Generation**: Can be triggered automatically when a request is approved/rejected
|
||||
- **Manual Generation**: Users can regenerate conclusions on demand
|
||||
- **Editable**: Generated remarks can be edited before finalization
|
||||
- **Enterprise Security**: Uses same service account credentials as Google Cloud Storage
|
||||
|
||||
### Use Cases
|
||||
1. **Automatic Generation**: When the final approver approves/rejects a request, an AI conclusion is generated in the background
|
||||
@ -74,10 +75,10 @@ The AI Conclusion Remark Generation feature automatically generates professional
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ AI Providers (Claude/OpenAI/Gemini) │ │
|
||||
│ │ - ClaudeProvider │ │
|
||||
│ │ - OpenAIProvider │ │
|
||||
│ │ - GeminiProvider │ │
|
||||
│ │ Vertex AI Gemini (Google Cloud) │ │
|
||||
│ │ - VertexAI Client │ │
|
||||
│ │ - Service Account Authentication │ │
|
||||
│ │ - Gemini Models (gemini-2.5-flash, etc.) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
@ -114,22 +115,18 @@ The AI Conclusion Remark Generation feature automatically generates professional
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# AI Provider Selection (claude, openai, gemini)
|
||||
AI_PROVIDER=claude
|
||||
# Google Cloud Configuration (required - same as GCS)
|
||||
GCP_PROJECT_ID=re-platform-workflow-dealer
|
||||
GCP_KEY_FILE=./credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
|
||||
|
||||
# Claude Configuration
|
||||
CLAUDE_API_KEY=your_claude_api_key
|
||||
CLAUDE_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# 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
|
||||
# Vertex AI Configuration (optional - defaults provided)
|
||||
VERTEX_AI_MODEL=gemini-2.5-flash
|
||||
VERTEX_AI_LOCATION=asia-south1
|
||||
AI_ENABLED=true
|
||||
```
|
||||
|
||||
**Note**: The service account key file is the same one used for Google Cloud Storage, ensuring consistent authentication across services.
|
||||
|
||||
### Admin Configuration (Database)
|
||||
|
||||
The system reads configuration from the `system_config` table. Key settings:
|
||||
@ -138,21 +135,29 @@ The system reads configuration from the `system_config` table. Key settings:
|
||||
|------------|---------|-------------|
|
||||
| `AI_ENABLED` | `true` | Enable/disable all AI features |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `VERTEX_AI_MODEL` | `gemini-2.5-flash` | Vertex AI Gemini model name |
|
||||
|
||||
### Provider Priority
|
||||
### Available Models
|
||||
|
||||
1. **Preferred Provider**: Set via `AI_PROVIDER` config
|
||||
2. **Fallback Chain**: If preferred fails, tries:
|
||||
- Claude → OpenAI → Gemini
|
||||
3. **Environment Fallback**: If database config fails, uses environment variables
|
||||
| Model Name | Description | Use Case |
|
||||
|------------|-------------|----------|
|
||||
| `gemini-2.5-flash` | Latest fast model (default) | General purpose, quick responses |
|
||||
| `gemini-1.5-flash` | Previous fast model | General purpose |
|
||||
| `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.
|
||||
|
||||
---
|
||||
|
||||
@ -186,7 +191,7 @@ Authorization: Bearer <token>
|
||||
],
|
||||
"confidence": 0.85,
|
||||
"generatedAt": "2025-01-15T10:30:00Z",
|
||||
"provider": "Claude (Anthropic)"
|
||||
"provider": "Vertex AI (Gemini)"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -254,7 +259,7 @@ Content-Type: application/json
|
||||
"finalRemark": "Finalized text...",
|
||||
"isEdited": true,
|
||||
"editCount": 2,
|
||||
"aiModelUsed": "Claude (Anthropic)",
|
||||
"aiModelUsed": "Vertex AI (Gemini)",
|
||||
"aiConfidenceScore": 0.85,
|
||||
"keyDiscussionPoints": ["Point 1", "Point 2"],
|
||||
"generatedAt": "2025-01-15T10:30:00Z",
|
||||
@ -324,9 +329,9 @@ interface ConclusionContext {
|
||||
- Sets target word count based on `AI_MAX_REMARK_LENGTH`
|
||||
|
||||
3. **AI Generation**:
|
||||
- Sends prompt to selected AI provider
|
||||
- Receives generated text
|
||||
- Validates length (trims if exceeds max)
|
||||
- Sends prompt to Vertex AI Gemini
|
||||
- Receives generated text (up to 4096 tokens)
|
||||
- Preserves full AI response (no truncation)
|
||||
- Extracts key points
|
||||
- Calculates confidence score
|
||||
|
||||
@ -407,13 +412,24 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
|
||||
4. **Tone Guidelines**: Emphasizes natural, professional, archival-quality writing
|
||||
5. **Context Awareness**: Includes all relevant data (approvals, notes, documents, activities)
|
||||
|
||||
### Provider-Specific Settings
|
||||
### Vertex AI Settings
|
||||
|
||||
| Provider | Model | Max Tokens | Temperature | Notes |
|
||||
|----------|-------|------------|-------------|-------|
|
||||
| Claude | claude-sonnet-4-20250514 | 2048 | 0.3 | Best for longer, detailed conclusions |
|
||||
| OpenAI | gpt-4o | 1024 | 0.3 | Balanced performance |
|
||||
| Gemini | gemini-2.0-flash-lite | - | 0.3 | Fast and cost-effective |
|
||||
| Setting | Value | Description |
|
||||
|---------|-------|-------------|
|
||||
| Model | `gemini-2.5-flash` (default) | Fast, efficient model for conclusion generation |
|
||||
| Max Output Tokens | `4096` | Maximum tokens in response (technical limit) |
|
||||
| Character Limit | `2000` (configurable) | Actual limit enforced via prompt (`AI_MAX_REMARK_LENGTH`) |
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
@ -423,15 +439,21 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
|
||||
|
||||
1. **No AI Provider Available**
|
||||
```
|
||||
Error: AI features are currently unavailable. Please configure an AI provider...
|
||||
Error: AI features are currently unavailable. Please verify Vertex AI configuration and service account credentials.
|
||||
```
|
||||
**Solution**: Configure API keys in admin panel or environment variables
|
||||
**Solution**:
|
||||
- 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. **Provider API Error**
|
||||
2. **Vertex AI API Error**
|
||||
```
|
||||
Error: AI generation failed (Claude): API rate limit exceeded
|
||||
Error: AI generation failed (Vertex AI): Model was not found or your project does not have access
|
||||
```
|
||||
**Solution**: Check API key validity, rate limits, and provider status
|
||||
**Solution**:
|
||||
- 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**
|
||||
```
|
||||
@ -453,10 +475,10 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
|
||||
|
||||
### Error Recovery
|
||||
|
||||
- **Automatic Fallback**: If preferred provider fails, system tries fallback providers
|
||||
- **Graceful Degradation**: If AI generation fails, user can write conclusion manually
|
||||
- **Retry Logic**: Manual regeneration is always available
|
||||
- **Logging**: All errors are logged with context for debugging
|
||||
- **Token Limit Handling**: If response hits token limit, full response is preserved (no truncation)
|
||||
|
||||
---
|
||||
|
||||
@ -472,14 +494,17 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
|
||||
|
||||
### For Administrators
|
||||
|
||||
1. **API Key Management**: Store API keys securely in database or environment variables
|
||||
2. **Provider Selection**: Choose provider based on:
|
||||
- **Claude**: Best quality, higher cost
|
||||
- **OpenAI**: Balanced quality/cost
|
||||
- **Gemini**: Fast, cost-effective
|
||||
1. **Service Account Setup**:
|
||||
- Ensure service account key file exists and is accessible
|
||||
- Verify service account has `Vertex AI User` role
|
||||
- Use same credentials as Google Cloud Storage for consistency
|
||||
2. **Model Selection**: Choose model based on needs:
|
||||
- **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
|
||||
4. **Monitoring**: Monitor AI usage and costs through provider dashboards
|
||||
4. **Monitoring**: Monitor AI usage and costs through Google Cloud Console
|
||||
5. **Testing**: Test with sample requests before enabling in production
|
||||
6. **Region Selection**: Choose region closest to your deployment for lower latency
|
||||
|
||||
### For Users
|
||||
|
||||
@ -499,8 +524,10 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
|
||||
**Diagnosis**:
|
||||
1. Check `AI_ENABLED` config value
|
||||
2. Check `AI_REMARK_GENERATION_ENABLED` config value
|
||||
3. Verify API keys are configured
|
||||
4. Check provider initialization logs
|
||||
3. Verify service account key file exists and is accessible
|
||||
4. Check Vertex AI API is enabled in Google Cloud Console
|
||||
5. Verify service account has `Vertex AI User` role
|
||||
6. Check provider initialization logs
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
@ -509,6 +536,14 @@ tail -f logs/app.log | grep "AI Service"
|
||||
|
||||
# Verify config
|
||||
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
|
||||
@ -518,7 +553,8 @@ SELECT * FROM system_config WHERE config_key LIKE 'AI_%';
|
||||
**Solution**:
|
||||
1. Adjust `AI_MAX_REMARK_LENGTH` in admin config
|
||||
2. Check prompt target word count calculation
|
||||
3. Verify provider max_tokens setting
|
||||
3. Note: Vertex AI max output tokens is 4096 (system handles this automatically)
|
||||
4. AI is instructed to stay within character limit, but full response is preserved
|
||||
|
||||
### Issue: Poor Quality Conclusions
|
||||
|
||||
@ -527,37 +563,50 @@ SELECT * FROM system_config WHERE config_key LIKE 'AI_%';
|
||||
**Solution**:
|
||||
1. Verify context data is complete (approvals, notes, documents)
|
||||
2. Check prompt includes all relevant information
|
||||
3. Try different provider (Claude generally produces better quality)
|
||||
4. Adjust temperature if needed (lower = more focused)
|
||||
3. Try different model (e.g., `gemini-1.5-pro` for better quality)
|
||||
4. Temperature is set to 0.3 for focused output (can be adjusted in code if needed)
|
||||
|
||||
### Issue: Slow Generation
|
||||
|
||||
**Symptoms**: AI generation takes too long
|
||||
|
||||
**Solution**:
|
||||
1. Check provider API status
|
||||
1. Check Vertex AI API status in Google Cloud Console
|
||||
2. Verify network connectivity
|
||||
3. Consider using faster provider (Gemini)
|
||||
4. Check for rate limiting
|
||||
3. Consider using `gemini-2.5-flash` model (fastest option)
|
||||
4. Check for rate limiting in Google Cloud Console
|
||||
5. Verify region selection (closer region = lower latency)
|
||||
|
||||
### Issue: Provider Not Initializing
|
||||
### Issue: Vertex AI Not Initializing
|
||||
|
||||
**Symptoms**: Provider shows as "None" in logs
|
||||
**Symptoms**: Provider shows as "None" or initialization fails in logs
|
||||
|
||||
**Diagnosis**:
|
||||
1. Check API key is valid
|
||||
2. Verify SDK package is installed
|
||||
3. Check environment variables
|
||||
1. Check service account key file exists and is valid
|
||||
2. Verify `@google-cloud/vertexai` package is installed
|
||||
3. Check environment variables (`GCP_PROJECT_ID`, `GCP_KEY_FILE`)
|
||||
4. Verify Vertex AI API is enabled in Google Cloud Console
|
||||
5. Check service account permissions
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Install missing SDK
|
||||
npm install @anthropic-ai/sdk # For Claude
|
||||
npm install openai # For OpenAI
|
||||
npm install @google/generative-ai # For Gemini
|
||||
npm install @google-cloud/vertexai
|
||||
|
||||
# Verify API key
|
||||
echo $CLAUDE_API_KEY # Should show key
|
||||
# Verify service account key file
|
||||
ls -la credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
---
|
||||
@ -644,12 +693,13 @@ reference.
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0.0** (2025-01-15): Initial implementation
|
||||
- Multi-provider support (Claude, OpenAI, Gemini)
|
||||
- Automatic and manual generation
|
||||
- TAT risk integration
|
||||
- Key points extraction
|
||||
- Confidence scoring
|
||||
- **v2.0.0**: Vertex AI Migration
|
||||
- Migrated to Google Cloud Vertex AI Gemini
|
||||
- Service account authentication (same as GCS)
|
||||
- Removed multi-provider support
|
||||
- Increased max output tokens to 4096
|
||||
- Full response preservation (no truncation)
|
||||
- HTML format support for rich text editor
|
||||
|
||||
---
|
||||
|
||||
@ -659,13 +709,18 @@ For issues or questions:
|
||||
1. Check logs: `logs/app.log`
|
||||
2. Review admin configuration panel
|
||||
3. Contact development team
|
||||
4. Refer to provider documentation:
|
||||
- [Claude API Docs](https://docs.anthropic.com)
|
||||
- [OpenAI API Docs](https://platform.openai.com/docs)
|
||||
- [Gemini API Docs](https://ai.google.dev/docs)
|
||||
4. Refer to Vertex AI documentation:
|
||||
- [Vertex AI Documentation](https://cloud.google.com/vertex-ai/docs)
|
||||
- [Gemini Models](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini)
|
||||
- [Vertex AI Setup Guide](../VERTEX_AI_INTEGRATION.md)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Maintained By**: Royal Enfield Development Team
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Vertex AI Integration Guide](./VERTEX_AI_INTEGRATION.md) - Detailed setup and migration information
|
||||
|
||||
|
||||
224
docs/DEALERS_CSV_IMPORT_FIX.sql
Normal file
224
docs/DEALERS_CSV_IMPORT_FIX.sql
Normal file
@ -0,0 +1,224 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
|
||||
515
docs/DEALERS_CSV_IMPORT_GUIDE.md
Normal file
515
docs/DEALERS_CSV_IMPORT_GUIDE.md
Normal file
@ -0,0 +1,515 @@
|
||||
# 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
|
||||
|
||||
277
docs/GOOGLE_SECRET_MANAGER_SETUP.md
Normal file
277
docs/GOOGLE_SECRET_MANAGER_SETUP.md
Normal file
@ -0,0 +1,277 @@
|
||||
# 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)
|
||||
|
||||
201
docs/TANFLOW_USER_DATA_MAPPING.md
Normal file
201
docs/TANFLOW_USER_DATA_MAPPING.md
Normal file
@ -0,0 +1,201 @@
|
||||
# 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
|
||||
|
||||
@ -30,6 +30,13 @@ GCP_PROJECT_ID=re-workflow-project
|
||||
GCP_BUCKET_NAME=re-workflow-documents
|
||||
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)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
|
||||
395
package-lock.json
generated
395
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "re-workflow-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/vertexai": "^1.10.0",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
@ -1614,6 +1615,18 @@
|
||||
"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": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz",
|
||||
@ -1682,6 +1695,37 @@
|
||||
"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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@ -1744,7 +1788,6 @@
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^5.1.2",
|
||||
@ -1762,7 +1805,6 @@
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -1775,7 +1817,6 @@
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -1788,14 +1829,12 @@
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui/node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"eastasianwidth": "^0.2.0",
|
||||
@ -1813,7 +1852,6 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.0.1"
|
||||
@ -1829,7 +1867,6 @@
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.1.0",
|
||||
@ -2302,6 +2339,16 @@
|
||||
"@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": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
||||
@ -2761,7 +2808,6 @@
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
@ -4348,7 +4394,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -4358,7 +4403,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@ -4629,7 +4673,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
@ -4785,7 +4828,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@ -5095,7 +5137,6 @@
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
@ -5150,7 +5191,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@ -5163,7 +5203,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
@ -5439,7 +5478,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@ -5450,6 +5488,15 @@
|
||||
"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": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
@ -5664,7 +5711,6 @@
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
@ -5741,7 +5787,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enabled": {
|
||||
@ -5903,7 +5948,6 @@
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@ -6408,6 +6452,29 @@
|
||||
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
|
||||
"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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@ -6535,7 +6602,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6",
|
||||
@ -6552,7 +6618,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@ -6577,6 +6642,18 @@
|
||||
"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": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
@ -6717,7 +6794,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@ -6916,6 +6992,203 @@
|
||||
"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": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
@ -7334,7 +7607,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -7395,7 +7667,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
@ -7473,7 +7744,6 @@
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/cliui": "^8.0.2"
|
||||
@ -8360,6 +8630,12 @@
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"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": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@ -8623,7 +8899,6 @@
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
@ -8648,7 +8923,6 @@
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@ -8841,6 +9115,26 @@
|
||||
"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": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@ -9023,6 +9317,15 @@
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@ -9174,7 +9477,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
@ -9278,7 +9580,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -9295,7 +9596,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^10.2.0",
|
||||
@ -9312,7 +9612,6 @@
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
@ -9682,6 +9981,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
@ -9888,7 +10199,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -10313,7 +10623,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@ -10326,7 +10635,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@ -10722,7 +11030,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@ -10738,7 +11045,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@ -10753,7 +11059,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@ -10767,7 +11072,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@ -11620,6 +11924,15 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
@ -11640,7 +11953,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@ -11734,7 +12046,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@ -11753,7 +12064,6 @@
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@ -11823,7 +12133,6 @@
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@ -11840,7 +12149,6 @@
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
@ -11859,7 +12167,6 @@
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
"description": "Royal Enfield Workflow Management System - Backend API (TypeScript)",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"start": "npm run setup && npm run build && npm run start:prod",
|
||||
"dev": "npm run setup && npm run migrate && nodemon --exec ts-node -r tsconfig-paths/register src/server.ts",
|
||||
"start": "npm run build && npm run start:prod && npm run setup",
|
||||
"dev": "npm run 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:watch": "tsc --watch",
|
||||
@ -18,10 +18,11 @@
|
||||
"setup": "ts-node -r tsconfig-paths/register src/scripts/auto-setup.ts",
|
||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/migrate.ts",
|
||||
"seed:config": "ts-node -r tsconfig-paths/register src/scripts/seed-admin-config.ts",
|
||||
"seed:dealers": "ts-node -r tsconfig-paths/register src/scripts/seed-dealers.ts",
|
||||
"seed:test-dealer": "ts-node -r tsconfig-paths/register src/scripts/seed-test-dealer.ts",
|
||||
"cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/secret-manager": "^6.1.1",
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/vertexai": "^1.10.0",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
|
||||
11
secret-map.example.json
Normal file
11
secret-map.example.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"_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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,11 +10,14 @@ import { corsMiddleware } from './middlewares/cors.middleware';
|
||||
import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware';
|
||||
import routes from './routes/index';
|
||||
import { ensureUploadDir, UPLOAD_DIR } from './config/storage';
|
||||
import { initializeGoogleSecretManager } from './services/googleSecretManager.service';
|
||||
import path from 'path';
|
||||
|
||||
// Load environment variables
|
||||
// Load environment variables from .env file first
|
||||
dotenv.config();
|
||||
|
||||
// Secrets are now initialized in server.ts before app is imported
|
||||
|
||||
const app: express.Application = express();
|
||||
const userService = new UserService();
|
||||
|
||||
|
||||
@ -1,17 +1,25 @@
|
||||
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 = {
|
||||
jwtSecret: process.env.JWT_SECRET || '',
|
||||
jwtExpiry: process.env.JWT_EXPIRY || '24h',
|
||||
refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY || '7d',
|
||||
sessionSecret: process.env.SESSION_SECRET || '',
|
||||
get jwtSecret() { return process.env.JWT_SECRET || ''; },
|
||||
get jwtExpiry() { return process.env.JWT_EXPIRY || '24h'; },
|
||||
get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; },
|
||||
get sessionSecret() { return process.env.SESSION_SECRET || ''; },
|
||||
// Use only FRONTEND_URL from environment - no fallbacks
|
||||
allowedOrigins: process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [],
|
||||
get allowedOrigins() {
|
||||
return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [];
|
||||
},
|
||||
// Okta/Auth0 configuration for token exchange
|
||||
oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com',
|
||||
oktaClientId: process.env.OKTA_CLIENT_ID || '',
|
||||
oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '',
|
||||
oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API
|
||||
get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; },
|
||||
get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; },
|
||||
get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; },
|
||||
get oktaApiToken() { return 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 };
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Holiday, HolidayType } from '@models/Holiday';
|
||||
import { holidayService } from '@services/holiday.service';
|
||||
import { activityTypeService } from '@services/activityType.service';
|
||||
import { sequelize } from '@config/database';
|
||||
import { QueryTypes, Op } from 'sequelize';
|
||||
import logger from '@utils/logger';
|
||||
@ -249,7 +250,7 @@ export const getPublicConfigurations = async (req: Request, res: Response): Prom
|
||||
const { category } = req.query;
|
||||
|
||||
// Only allow certain categories for public access
|
||||
const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING'];
|
||||
const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING', 'SYSTEM_SETTINGS'];
|
||||
if (category && !allowedCategories.includes(category as string)) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
@ -262,7 +263,7 @@ export const getPublicConfigurations = async (req: Request, res: Response): Prom
|
||||
if (category) {
|
||||
whereClause = `WHERE config_category = '${category}' AND is_sensitive = false`;
|
||||
} else {
|
||||
whereClause = `WHERE config_category IN ('DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING') AND is_sensitive = false`;
|
||||
whereClause = `WHERE config_category IN ('DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING', 'SYSTEM_SETTINGS') AND is_sensitive = false`;
|
||||
}
|
||||
|
||||
const rawConfigurations = await sequelize.query(`
|
||||
@ -878,3 +879,174 @@ 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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { Request, Response } from 'express';
|
||||
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 { ResponseHandler } from '@utils/responseHandler';
|
||||
import type { AuthenticatedRequest } from '../types/express';
|
||||
import { getRequestMetadata } from '@utils/requestUtils';
|
||||
|
||||
const approvalService = new ApprovalService();
|
||||
const dealerClaimApprovalService = new DealerClaimApprovalService();
|
||||
|
||||
export class ApprovalController {
|
||||
async approveLevel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
@ -13,18 +17,54 @@ export class ApprovalController {
|
||||
const { levelId } = req.params;
|
||||
const validatedData = validateApprovalAction(req.body);
|
||||
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
const level = await approvalService.approveLevel(levelId, validatedData, req.user.userId, {
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent
|
||||
});
|
||||
|
||||
// Determine which service to use based on workflow type
|
||||
const level = await ApprovalLevel.findByPk(levelId);
|
||||
if (!level) {
|
||||
ResponseHandler.notFound(res, 'Approval level not found');
|
||||
return;
|
||||
}
|
||||
|
||||
ResponseHandler.success(res, level, 'Approval level updated successfully');
|
||||
const workflow = await WorkflowRequest.findByPk(level.requestId);
|
||||
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) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
ResponseHandler.error(res, 'Failed to update approval level', 400, errorMessage);
|
||||
@ -34,7 +74,23 @@ export class ApprovalController {
|
||||
async getCurrentApprovalLevel(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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');
|
||||
} catch (error) {
|
||||
@ -46,7 +102,23 @@ export class ApprovalController {
|
||||
async getApprovalLevels(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
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');
|
||||
} catch (error) {
|
||||
|
||||
@ -84,6 +84,7 @@ export class AuthController {
|
||||
displayName: user.displayName,
|
||||
department: user.department,
|
||||
designation: user.designation,
|
||||
jobTitle: user.jobTitle,
|
||||
phone: user.phone,
|
||||
location: user.location,
|
||||
role: user.role,
|
||||
@ -159,6 +160,128 @@ 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
|
||||
* POST /api/v1/auth/logout
|
||||
|
||||
@ -79,7 +79,7 @@ export class ConclusionController {
|
||||
const workNotes = await WorkNote.findAll({
|
||||
where: { requestId },
|
||||
order: [['createdAt', 'ASC']],
|
||||
limit: 20 // Last 20 work notes
|
||||
limit: 20 // Last 20 work notes - keep full context for better conclusions
|
||||
});
|
||||
|
||||
const documents = await Document.findAll({
|
||||
@ -90,7 +90,7 @@ export class ConclusionController {
|
||||
const activities = await Activity.findAll({
|
||||
where: { requestId },
|
||||
order: [['createdAt', 'ASC']],
|
||||
limit: 50 // Last 50 activities
|
||||
limit: 50 // Last 50 activities - keep full context for better conclusions
|
||||
});
|
||||
|
||||
// Build context object
|
||||
|
||||
@ -46,6 +46,7 @@ export class DashboardController {
|
||||
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 priority = req.query.priority as string | undefined;
|
||||
const templateType = req.query.templateType as string | undefined;
|
||||
const department = req.query.department as string | undefined;
|
||||
const initiator = req.query.initiator as string | undefined;
|
||||
const approver = req.query.approver as string | undefined;
|
||||
@ -61,6 +62,7 @@ export class DashboardController {
|
||||
endDate,
|
||||
status,
|
||||
priority,
|
||||
templateType,
|
||||
department,
|
||||
initiator,
|
||||
approver,
|
||||
|
||||
@ -7,11 +7,15 @@ import logger from '../utils/logger';
|
||||
export class DealerController {
|
||||
/**
|
||||
* Get all dealers
|
||||
* GET /api/v1/dealers
|
||||
* GET /api/v1/dealers?q=searchTerm&limit=10 (optional search and limit)
|
||||
*/
|
||||
async getAllDealers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const dealers = await dealerService.getAllDealers();
|
||||
const searchTerm = req.query.q as string | undefined;
|
||||
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');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -64,17 +68,19 @@ export class DealerController {
|
||||
|
||||
/**
|
||||
* Search dealers
|
||||
* GET /api/v1/dealers/search?q=searchTerm
|
||||
* GET /api/v1/dealers/search?q=searchTerm&limit=10
|
||||
*/
|
||||
async searchDealers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
const { q, limit: limitParam } = req.query;
|
||||
|
||||
if (!q || typeof q !== 'string') {
|
||||
return ResponseHandler.error(res, 'Search term is required', 400);
|
||||
}
|
||||
|
||||
const dealers = await dealerService.searchDealers(q);
|
||||
// Parse limit, default to 10, max 100
|
||||
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');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
@ -82,5 +88,36 @@ export class DealerController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -250,6 +250,7 @@ export class DealerClaimController {
|
||||
numberOfParticipants,
|
||||
closedExpenses,
|
||||
totalClosedExpenses,
|
||||
completionDescription,
|
||||
} = req.body;
|
||||
|
||||
// Parse closedExpenses if it's a JSON string
|
||||
@ -540,6 +541,7 @@ export class DealerClaimController {
|
||||
totalClosedExpenses: totalClosedExpenses ? parseFloat(totalClosedExpenses) : 0,
|
||||
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
|
||||
attendanceSheet: attendanceSheet || undefined,
|
||||
completionDescription: completionDescription || undefined,
|
||||
});
|
||||
|
||||
return ResponseHandler.success(res, { message: 'Completion documents submitted successfully' }, 'Completion submitted');
|
||||
@ -838,5 +840,88 @@ export class DealerClaimController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
src/controllers/dealerDashboard.controller.ts
Normal file
39
src/controllers/dealerDashboard.controller.ts
Normal file
@ -0,0 +1,39 @@
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,9 +5,14 @@ import fs from 'fs';
|
||||
import { Document } from '@models/Document';
|
||||
import { User } from '@models/User';
|
||||
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 { activityService } from '@services/activity.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 { getRequestMetadata } from '@utils/requestUtils';
|
||||
import { getConfigNumber, getConfigValue } from '@services/configReader.service';
|
||||
@ -133,16 +138,84 @@ export class DocumentController {
|
||||
}
|
||||
}
|
||||
|
||||
const doc = await Document.create({
|
||||
// Check if storageUrl exceeds database column limit (500 chars)
|
||||
// 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,
|
||||
uploadedBy: userId,
|
||||
fileName: path.basename(file.filename || file.originalname),
|
||||
originalFileName: file.originalname,
|
||||
fileName: truncatedFileName,
|
||||
originalFileName: truncatedOriginalFileName,
|
||||
fileType: extension,
|
||||
fileExtension: extension,
|
||||
fileSize: file.size,
|
||||
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,
|
||||
checksum,
|
||||
isGoogleDoc: false,
|
||||
@ -152,7 +225,43 @@ export class DocumentController {
|
||||
parentDocumentId: null as any,
|
||||
isDeleted: false,
|
||||
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
|
||||
logDocumentEvent('uploaded', doc.documentId, {
|
||||
@ -187,6 +296,205 @@ export class DocumentController {
|
||||
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);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
@ -19,6 +19,7 @@ export class TemplateController {
|
||||
}
|
||||
|
||||
const {
|
||||
// New fields
|
||||
templateName,
|
||||
templateCode,
|
||||
templateDescription,
|
||||
@ -30,20 +31,34 @@ export class TemplateController {
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
isActive,
|
||||
|
||||
// Legacy fields (from frontend)
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
approvers,
|
||||
suggestedSLA
|
||||
} = req.body;
|
||||
|
||||
if (!templateName) {
|
||||
// Map legacy to new
|
||||
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);
|
||||
}
|
||||
|
||||
const template = await this.templateService.createTemplate(userId, {
|
||||
templateName,
|
||||
templateName: finalTemplateName,
|
||||
templateCode,
|
||||
templateDescription,
|
||||
templateCategory,
|
||||
templateDescription: finalTemplateDescription,
|
||||
templateCategory: finalTemplateCategory,
|
||||
workflowType,
|
||||
approvalLevelsConfig,
|
||||
defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
|
||||
approvalLevelsConfig: finalApprovalLevelsConfig,
|
||||
defaultTatHours: finalDefaultTatHours ? parseFloat(finalDefaultTatHours) : undefined,
|
||||
formStepsConfig,
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
@ -149,14 +164,21 @@ export class TemplateController {
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
isActive,
|
||||
|
||||
// Legacy
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
approvers,
|
||||
suggestedSLA
|
||||
} = req.body;
|
||||
|
||||
const template = await this.templateService.updateTemplate(templateId, userId, {
|
||||
templateName,
|
||||
templateDescription,
|
||||
templateCategory,
|
||||
approvalLevelsConfig,
|
||||
defaultTatHours: defaultTatHours ? parseFloat(defaultTatHours) : undefined,
|
||||
templateName: templateName || name,
|
||||
templateDescription: templateDescription || description,
|
||||
templateCategory: templateCategory || category,
|
||||
approvalLevelsConfig: approvalLevelsConfig || approvers,
|
||||
defaultTatHours: (defaultTatHours || suggestedSLA) ? parseFloat(defaultTatHours || suggestedSLA) : undefined,
|
||||
formStepsConfig,
|
||||
userFieldMappings,
|
||||
dynamicApproverConfig,
|
||||
|
||||
@ -13,9 +13,11 @@ import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { getRequestMetadata } from '@utils/requestUtils';
|
||||
import { enrichApprovalLevels, enrichSpectators, validateInitiator } from '@services/userEnrichment.service';
|
||||
import { DealerClaimService } from '@services/dealerClaim.service';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
const workflowService = new WorkflowService();
|
||||
const dealerClaimService = new DealerClaimService();
|
||||
|
||||
export class WorkflowController {
|
||||
async createWorkflow(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
@ -279,16 +281,85 @@ 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', {
|
||||
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: path.basename(file.filename || file.originalname),
|
||||
originalFileName: file.originalname,
|
||||
fileName: truncatedFileName,
|
||||
originalFileName: truncatedOriginalFileName,
|
||||
fileType: extension,
|
||||
fileExtension: extension,
|
||||
fileSize: file.size,
|
||||
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,
|
||||
checksum,
|
||||
isGoogleDoc: false,
|
||||
@ -300,6 +371,24 @@ export class WorkflowController {
|
||||
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
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
@ -320,6 +409,13 @@ export class WorkflowController {
|
||||
ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201);
|
||||
} catch (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);
|
||||
}
|
||||
}
|
||||
@ -380,6 +476,7 @@ export class WorkflowController {
|
||||
search: req.query.search as string | undefined,
|
||||
status: req.query.status as string | undefined,
|
||||
priority: req.query.priority as string | undefined,
|
||||
templateType: req.query.templateType as string | undefined,
|
||||
department: req.query.department as string | undefined,
|
||||
initiator: req.query.initiator as string | undefined,
|
||||
approver: req.query.approver as string | undefined,
|
||||
@ -441,6 +538,7 @@ export class WorkflowController {
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status 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 initiator = req.query.initiator as string | undefined;
|
||||
const approver = req.query.approver as string | undefined;
|
||||
@ -450,7 +548,7 @@ export class WorkflowController {
|
||||
const startDate = req.query.startDate as string | undefined;
|
||||
const endDate = req.query.endDate as string | undefined;
|
||||
|
||||
const filters = { search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
const filters = { search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listParticipantRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'Participant requests fetched');
|
||||
@ -473,13 +571,14 @@ export class WorkflowController {
|
||||
const search = req.query.search as string | undefined;
|
||||
const status = req.query.status 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 slaCompliance = req.query.slaCompliance as string | undefined;
|
||||
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 filters = { search, status, priority, department, slaCompliance, dateRange, startDate, endDate };
|
||||
const filters = { search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate };
|
||||
|
||||
const result = await workflowService.listMyInitiatedRequests(userId, page, limit, filters);
|
||||
ResponseHandler.success(res, result, 'My initiated requests fetched');
|
||||
@ -499,7 +598,8 @@ export class WorkflowController {
|
||||
const filters = {
|
||||
search: req.query.search 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
|
||||
@ -524,7 +624,8 @@ export class WorkflowController {
|
||||
const filters = {
|
||||
search: req.query.search 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
|
||||
@ -587,11 +688,28 @@ export class WorkflowController {
|
||||
}
|
||||
|
||||
// Update workflow
|
||||
const workflow = await workflowService.updateWorkflow(id, updateData);
|
||||
let workflow;
|
||||
try {
|
||||
workflow = await workflowService.updateWorkflow(id, updateData);
|
||||
if (!workflow) {
|
||||
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
|
||||
const files = (req as any).files as Express.Multer.File[] | undefined;
|
||||
@ -627,23 +745,85 @@ export class WorkflowController {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[Workflow] Creating document record', {
|
||||
fileName: file.originalname,
|
||||
// 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,
|
||||
storageUrl: storageUrl,
|
||||
});
|
||||
// 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: actualRequestId
|
||||
});
|
||||
|
||||
try {
|
||||
const doc = await Document.create({
|
||||
requestId: actualRequestId,
|
||||
uploadedBy: userId,
|
||||
fileName: path.basename(file.filename || file.originalname),
|
||||
originalFileName: file.originalname,
|
||||
fileName: truncatedFileName,
|
||||
originalFileName: truncatedOriginalFileName,
|
||||
fileType: extension,
|
||||
fileExtension: extension,
|
||||
fileSize: file.size,
|
||||
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,
|
||||
checksum,
|
||||
isGoogleDoc: false,
|
||||
@ -655,12 +835,39 @@ export class WorkflowController {
|
||||
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: 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);
|
||||
} catch (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);
|
||||
}
|
||||
}
|
||||
@ -681,4 +888,54 @@ export class WorkflowController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
src/controllers/workflowTemplate.controller.ts
Normal file
130
src/controllers/workflowTemplate.controller.ts
Normal file
@ -0,0 +1,130 @@
|
||||
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'
|
||||
});
|
||||
}
|
||||
};
|
||||
297
src/emailtemplates/ADDITIONAL_APPROVER_HANDLING.md
Normal file
297
src/emailtemplates/ADDITIONAL_APPROVER_HANDLING.md
Normal file
@ -0,0 +1,297 @@
|
||||
# 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
|
||||
|
||||
393
src/emailtemplates/DEALER_CLAIM_EMAIL_TEMPLATES.md
Normal file
393
src/emailtemplates/DEALER_CLAIM_EMAIL_TEMPLATES.md
Normal file
@ -0,0 +1,393 @@
|
||||
# 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.
|
||||
|
||||
358
src/emailtemplates/DEALER_CLAIM_TEMPLATES_IMPLEMENTATION.md
Normal file
358
src/emailtemplates/DEALER_CLAIM_TEMPLATES_IMPLEMENTATION.md
Normal file
@ -0,0 +1,358 @@
|
||||
# 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!
|
||||
|
||||
180
src/emailtemplates/activityCreated.template.ts
Normal file
180
src/emailtemplates/activityCreated.template.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
152
src/emailtemplates/additionalDocumentAdded.template.ts
Normal file
152
src/emailtemplates/additionalDocumentAdded.template.ts
Normal file
@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -3,14 +3,14 @@
|
||||
*/
|
||||
|
||||
import { ApprovalConfirmationData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string {
|
||||
const commentsSection = data.approverComments ? `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #28a745; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.approverComments)}
|
||||
</div>
|
||||
</div>
|
||||
@ -21,21 +21,24 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>Request Approved</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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Approved',
|
||||
...HeaderStyles.success
|
||||
}))}
|
||||
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<td class="email-content">
|
||||
<p style="margin: 0 0 20px; color: #333333; font-size: 16px; line-height: 1.6;">
|
||||
Dear <strong style="color: #28a745;">${data.initiatorName}</strong>,
|
||||
</p>
|
||||
@ -46,47 +49,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">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #155724; font-size: 18px; font-weight: 600;">Request Summary</h2>
|
||||
<td class="detail-box" style="padding: 30px;">
|
||||
<h2 style="margin: 0 0 25px; color: #155724; font-size: 20px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px; width: 140px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
<strong>Approved By:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
${data.approverName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
<strong>Approved On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
${data.approvalDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
<strong>Time:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
${data.approvalTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #155724; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #155724; font-size: 15px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ApprovalRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
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">
|
||||
<tr>
|
||||
<td style="padding: 40px 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Approval Request',
|
||||
@ -55,6 +55,16 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
${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>Initiator:</strong>
|
||||
@ -92,10 +102,10 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Description (supports rich text HTML) -->
|
||||
<!-- Description (supports rich text HTML including tables) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.requestDescription)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ApproverSkippedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
||||
@ -12,14 +12,17 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>Approver Skipped</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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Approval Level Skipped',
|
||||
...HeaderStyles.infoSecondary
|
||||
@ -96,7 +99,7 @@ export function getApproverSkippedEmail(data: ApproverSkippedData): string {
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #17a2b8; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.skipReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
180
src/emailtemplates/completionDocumentsSubmitted.template.ts
Normal file
180
src/emailtemplates/completionDocumentsSubmitted.template.ts
Normal file
@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
203
src/emailtemplates/creditNoteSent.template.ts
Normal file
203
src/emailtemplates/creditNoteSent.template.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
148
src/emailtemplates/dealerCompletionRequired.template.ts
Normal file
148
src/emailtemplates/dealerCompletionRequired.template.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
206
src/emailtemplates/dealerProposalRequired.template.ts
Normal file
206
src/emailtemplates/dealerProposalRequired.template.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
178
src/emailtemplates/dealerProposalSubmitted.template.ts
Normal file
178
src/emailtemplates/dealerProposalSubmitted.template.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
187
src/emailtemplates/einvoiceGenerated.template.ts
Normal file
187
src/emailtemplates/einvoiceGenerated.template.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -24,7 +24,15 @@ export enum EmailNotificationType {
|
||||
REQUEST_CLOSED = 'request_closed',
|
||||
WORKFLOW_PAUSED = 'workflow_paused',
|
||||
PARTICIPANT_ADDED = 'participant_added',
|
||||
APPROVER_SKIPPED = 'approver_skipped'
|
||||
SPECTATOR_ADDED = 'spectator_added',
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -115,6 +115,55 @@ export function getRichTextStyles(): string {
|
||||
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 */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.rich-text-content p,
|
||||
@ -127,6 +176,21 @@ export function getRichTextStyles(): string {
|
||||
.rich-text-content h2 { font-size: 16px !important; }
|
||||
.rich-text-content h3 { font-size: 15px !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>
|
||||
`;
|
||||
@ -135,18 +199,112 @@ export function getRichTextStyles(): string {
|
||||
/**
|
||||
* Wrap rich text content with proper styling
|
||||
* 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 {
|
||||
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 `
|
||||
<div class="rich-text-content" style="color: #666666; font-size: 14px; line-height: 1.6;">
|
||||
${htmlContent}
|
||||
${processedContent}
|
||||
</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)
|
||||
* Optimized for screens up to 600px width
|
||||
* Desktop-first design (optimized for browser) with mobile responsive breakpoints
|
||||
*/
|
||||
export function getResponsiveStyles(): string {
|
||||
return `
|
||||
@ -173,6 +331,100 @@ export function getResponsiveStyles(): string {
|
||||
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 */
|
||||
@media only screen and (max-width: 600px) {
|
||||
/* Container adjustments */
|
||||
@ -180,11 +432,12 @@ export function getResponsiveStyles(): string {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Header adjustments */
|
||||
.email-header {
|
||||
padding: 25px 15px 30px !important;
|
||||
padding: 25px 20px 30px !important;
|
||||
}
|
||||
|
||||
/* Content adjustments */
|
||||
@ -207,7 +460,7 @@ export function getResponsiveStyles(): string {
|
||||
/* Typography adjustments */
|
||||
.header-title {
|
||||
font-size: 20px !important;
|
||||
letter-spacing: 1px !important;
|
||||
letter-spacing: 0.5px !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
@ -215,21 +468,22 @@ export function getResponsiveStyles(): string {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Detail tables */
|
||||
/* Detail tables - stack on mobile */
|
||||
.detail-box {
|
||||
padding: 20px 15px !important;
|
||||
}
|
||||
|
||||
.detail-table td {
|
||||
font-size: 13px !important;
|
||||
padding: 6px 0 !important;
|
||||
font-size: 14px !important;
|
||||
padding: 8px 0 !important;
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 2px !important;
|
||||
margin-bottom: 4px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Button adjustments */
|
||||
@ -240,27 +494,28 @@ export function getResponsiveStyles(): string {
|
||||
padding: 16px 20px !important;
|
||||
font-size: 16px !important;
|
||||
box-sizing: border-box !important;
|
||||
min-width: auto !important;
|
||||
}
|
||||
|
||||
/* Section adjustments */
|
||||
.info-section {
|
||||
padding: 15px !important;
|
||||
padding: 18px 15px !important;
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
font-size: 13px !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.6 !important;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.info-section ul {
|
||||
padding-left: 15px !important;
|
||||
font-size: 13px !important;
|
||||
padding-left: 20px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
@ -524,7 +779,7 @@ export function getConclusionSection(conclusionRemark?: string): string {
|
||||
return `
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6f42c1; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(conclusionRemark)}
|
||||
</div>
|
||||
</div>
|
||||
@ -611,3 +866,29 @@ 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';
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,15 @@ export { getTATBreachedEmail } from './tatBreached.template';
|
||||
export { getWorkflowPausedEmail } from './workflowPaused.template';
|
||||
export { getWorkflowResumedEmail } from './workflowResumed.template';
|
||||
export { getParticipantAddedEmail } from './participantAdded.template';
|
||||
export { getSpectatorAddedEmail } from './spectatorAdded.template';
|
||||
export { getApproverSkippedEmail } from './approverSkipped.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';
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { MultiApproverRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
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">
|
||||
<tr>
|
||||
<td style="padding: 40px 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Multi-Level Approval Request',
|
||||
@ -55,6 +55,16 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
||||
${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>Initiator:</strong>
|
||||
@ -96,10 +106,10 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Description (supports rich text HTML) -->
|
||||
<!-- Description (supports rich text HTML including tables) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.requestDescription)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ParticipantAddedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getPermissionsContent, getRoleDescription, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
||||
@ -12,14 +12,17 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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 to Request</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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: `You've Been Added as ${data.participantRole}`,
|
||||
...HeaderStyles.info
|
||||
@ -104,7 +107,7 @@ export function getParticipantAddedEmail(data: ParticipantAddedData): string {
|
||||
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #667eea; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.requestDescription)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { RejectionNotificationData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getRejectionNotificationEmail(data: RejectionNotificationData): string {
|
||||
@ -12,14 +12,17 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>Request Rejected</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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Rejected',
|
||||
...HeaderStyles.error
|
||||
@ -88,7 +91,7 @@ export function getRejectionNotificationEmail(data: RejectionNotificationData):
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #dc3545; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.rejectionReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { RequestClosedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getConclusionSection, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
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">
|
||||
<tr>
|
||||
<td style="padding: 40px 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Closed',
|
||||
...HeaderStyles.complete
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { RequestCreatedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
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">
|
||||
<tr>
|
||||
<td style="padding: 40px 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Request Created Successfully',
|
||||
@ -31,7 +31,7 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="email-content" style="padding: 40px 30px;">
|
||||
<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.initiatorName}</strong>,
|
||||
</p>
|
||||
@ -43,55 +43,55 @@ export function getRequestCreatedEmail(data: RequestCreatedData): string {
|
||||
<!-- 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 Summary</h2>
|
||||
<td class="detail-box" style="padding: 30px;">
|
||||
<h2 style="margin: 0 0 25px; color: #333333; font-size: 20px; font-weight: 600;">Request Summary</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<table role="presentation" class="detail-table" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||
${data.requestTitle || 'N/A'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||
<strong>Priority:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||
${data.priority}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||
<strong>Created On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||
${data.requestDate} at ${data.requestTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<td class="detail-label" style="padding: 10px 0; color: #666666; font-size: 15px;">
|
||||
<strong>Total Approvers:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
<td style="padding: 10px 0; color: #333333; font-size: 15px;">
|
||||
${data.totalApprovers}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
155
src/emailtemplates/spectatorAdded.template.ts
Normal file
155
src/emailtemplates/spectatorAdded.template.ts
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { TATBreachedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getTATBreachedEmail(data: TATBreachedData): string {
|
||||
@ -12,14 +12,17 @@ export function getTATBreachedEmail(data: TATBreachedData): string {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>TAT Breached - Urgent Action 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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'TAT Breached',
|
||||
subtitle: 'Immediate Action Required',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { TATReminderData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
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">
|
||||
<tr>
|
||||
<td style="padding: 40px 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'TAT Reminder',
|
||||
subtitle: `${data.thresholdPercentage}% Elapsed - ${urgencyStyle.title}`,
|
||||
|
||||
@ -137,3 +137,112 @@ export interface RequestClosedData extends BaseEmailData {
|
||||
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'
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { WorkflowPausedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
||||
@ -12,14 +12,17 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>Workflow Paused</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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Workflow Paused',
|
||||
...HeaderStyles.neutral
|
||||
@ -88,7 +91,7 @@ export function getWorkflowPausedEmail(data: WorkflowPausedData): string {
|
||||
|
||||
<div style="margin-bottom: 30px;">
|
||||
<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;">
|
||||
<div style="padding: 15px; background-color: #f8f9fa; border-left: 4px solid #6c757d; border-radius: 4px; overflow-x: auto;">
|
||||
${wrapRichText(data.pauseReason)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { WorkflowResumedData } from './types';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection } from './helpers';
|
||||
import { getEmailFooter, getEmailHeader, HeaderStyles, getActionRequiredSection, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
||||
@ -12,14 +12,17 @@ export function getWorkflowResumedEmail(data: WorkflowResumedData): string {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>Workflow Resumed</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" 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">
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Workflow Resumed',
|
||||
...HeaderStyles.success
|
||||
|
||||
322
src/migrations/20250120-create-dealers-table.ts
Normal file
322
src/migrations/20250120-create-dealers-table.ts
Normal file
@ -0,0 +1,322 @@
|
||||
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');
|
||||
}
|
||||
|
||||
83
src/migrations/20250125-create-activity-types.ts
Normal file
83
src/migrations/20250125-create-activity-types.ts
Normal file
@ -0,0 +1,83 @@
|
||||
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');
|
||||
}
|
||||
|
||||
134
src/migrations/20260113-redesign-dealer-claim-history.ts
Normal file
134
src/migrations/20260113-redesign-dealer-claim-history.ts
Normal file
@ -0,0 +1,134 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
115
src/migrations/20260123-fix-template-id-schema.ts
Normal file
115
src/migrations/20260123-fix-template-id-schema.ts
Normal file
@ -0,0 +1,115 @@
|
||||
|
||||
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
|
||||
}
|
||||
127
src/models/ActivityType.ts
Normal file
127
src/models/ActivityType.ts
Normal file
@ -0,0 +1,127 @@
|
||||
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 };
|
||||
|
||||
442
src/models/Dealer.ts
Normal file
442
src/models/Dealer.ts
Normal file
@ -0,0 +1,442 @@
|
||||
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 };
|
||||
190
src/models/DealerClaimHistory.ts
Normal file
190
src/models/DealerClaimHistory.ts
Normal file
@ -0,0 +1,190 @@
|
||||
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 };
|
||||
@ -33,7 +33,7 @@ interface WorkflowRequestAttributes {
|
||||
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 {
|
||||
public requestId!: string;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '@config/database';
|
||||
import { sequelize } from '../config/database';
|
||||
import { User } from './User';
|
||||
|
||||
interface WorkflowTemplateAttributes {
|
||||
@ -22,9 +22,9 @@ interface WorkflowTemplateAttributes {
|
||||
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'> { }
|
||||
|
||||
class WorkflowTemplate extends Model<WorkflowTemplateAttributes, WorkflowTemplateCreationAttributes> implements WorkflowTemplateAttributes {
|
||||
export class WorkflowTemplate extends Model<WorkflowTemplateAttributes, WorkflowTemplateCreationAttributes> implements WorkflowTemplateAttributes {
|
||||
public templateId!: string;
|
||||
public templateName!: string;
|
||||
public templateCode?: string;
|
||||
@ -175,6 +175,3 @@ WorkflowTemplate.belongsTo(User, {
|
||||
foreignKey: 'createdBy',
|
||||
targetKey: 'userId'
|
||||
});
|
||||
|
||||
export { WorkflowTemplate };
|
||||
|
||||
|
||||
@ -20,9 +20,12 @@ import { DealerClaimDetails } from './DealerClaimDetails';
|
||||
import { DealerProposalDetails } from './DealerProposalDetails';
|
||||
import { DealerCompletionDetails } from './DealerCompletionDetails';
|
||||
import { DealerProposalCostItem } from './DealerProposalCostItem';
|
||||
import { WorkflowTemplate } from './WorkflowTemplate';
|
||||
import { InternalOrder } from './InternalOrder';
|
||||
import { ClaimBudgetTracking } from './ClaimBudgetTracking';
|
||||
import { Dealer } from './Dealer';
|
||||
import { ActivityType } from './ActivityType';
|
||||
import { DealerClaimHistory } from './DealerClaimHistory';
|
||||
import { WorkflowTemplate } from './WorkflowTemplate';
|
||||
|
||||
// Define associations
|
||||
const defineAssociations = () => {
|
||||
@ -135,6 +138,13 @@ const defineAssociations = () => {
|
||||
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
|
||||
// Only hasMany associations from WorkflowRequest are defined here since they're one-way
|
||||
};
|
||||
@ -160,13 +170,16 @@ export {
|
||||
ConclusionRemark,
|
||||
RequestSummary,
|
||||
SharedSummary,
|
||||
WorkflowTemplate,
|
||||
DealerClaimDetails,
|
||||
DealerProposalDetails,
|
||||
DealerCompletionDetails,
|
||||
DealerProposalCostItem,
|
||||
WorkflowTemplate,
|
||||
InternalOrder,
|
||||
ClaimBudgetTracking
|
||||
ClaimBudgetTracking,
|
||||
Dealer,
|
||||
ActivityType,
|
||||
DealerClaimHistory
|
||||
};
|
||||
|
||||
// Export default sequelize instance
|
||||
|
||||
@ -210,6 +210,13 @@ export async function handleTatJob(job: Job<TatJobData>) {
|
||||
type === 'threshold2' ? 'HIGH' :
|
||||
'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)
|
||||
try {
|
||||
await notificationService.sendToUsers([approverId], {
|
||||
@ -220,7 +227,17 @@ export async function handleTatJob(job: Job<TatJobData>) {
|
||||
url: `/request/${requestNumber}`,
|
||||
type: type,
|
||||
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}%)`);
|
||||
} catch (notificationError: any) {
|
||||
|
||||
@ -14,7 +14,12 @@ import {
|
||||
updateUserRole,
|
||||
getUsersByRole,
|
||||
getRoleStatistics,
|
||||
assignRoleByEmail
|
||||
assignRoleByEmail,
|
||||
getAllActivityTypes,
|
||||
getActivityTypeById,
|
||||
createActivityType,
|
||||
updateActivityType,
|
||||
deleteActivityType
|
||||
} from '@controllers/admin.controller';
|
||||
|
||||
const router = Router();
|
||||
@ -135,5 +140,48 @@ router.get('/users/by-role', getUsersByRole);
|
||||
*/
|
||||
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;
|
||||
|
||||
|
||||
@ -20,6 +20,18 @@ router.post('/token-exchange',
|
||||
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)
|
||||
router.post('/sso-callback',
|
||||
validateBody(ssoCallbackSchema),
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getPublicConfig } from '../config/system.config';
|
||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||
import { activityTypeService } from '../services/activityType.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -20,5 +21,27 @@ 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;
|
||||
|
||||
|
||||
@ -34,5 +34,12 @@ router.get('/code/:dealerCode', authenticateToken, asyncHandler(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;
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { DealerClaimController } from '../controllers/dealerClaim.controller';
|
||||
import { DealerDashboardController } from '../controllers/dealerDashboard.controller';
|
||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||
import multer from 'multer';
|
||||
@ -7,6 +8,7 @@ import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
const dealerClaimController = new DealerClaimController();
|
||||
const dealerDashboardController = new DealerDashboardController();
|
||||
|
||||
// Configure multer for file uploads (memory storage for direct GCS upload)
|
||||
const upload = multer({
|
||||
@ -25,6 +27,13 @@ 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
|
||||
* @desc Create a new dealer claim request
|
||||
@ -93,5 +102,13 @@ router.put('/:requestId/credit-note', authenticateToken, asyncHandler(dealerClai
|
||||
*/
|
||||
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;
|
||||
|
||||
|
||||
@ -21,6 +21,33 @@ import { pauseController } from '../controllers/pause.controller';
|
||||
import logger from '@utils/logger';
|
||||
|
||||
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 approvalController = new ApprovalController();
|
||||
const workNoteController = new WorkNoteController();
|
||||
@ -223,10 +250,125 @@ router.get('/documents/:documentId/preview',
|
||||
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/)
|
||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||
// File is served by express.static middleware, redirect to the storage URL
|
||||
res.redirect(storageUrl);
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = storageUrl.replace(/^\/uploads\//, '');
|
||||
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;
|
||||
}
|
||||
|
||||
@ -296,17 +438,122 @@ router.get('/documents/:documentId/download',
|
||||
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/)
|
||||
if (storageUrl && storageUrl.startsWith('/uploads/')) {
|
||||
// File is served by express.static middleware, redirect to the storage URL
|
||||
res.redirect(storageUrl);
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = storageUrl.replace(/^\/uploads\//, '');
|
||||
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;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const path = require('path');
|
||||
const { UPLOAD_DIR } = require('../config/storage');
|
||||
const absolutePath = filePath && !path.isAbsolute(filePath)
|
||||
? path.join(UPLOAD_DIR, filePath)
|
||||
: filePath;
|
||||
@ -341,15 +588,45 @@ router.get('/work-notes/attachments/:attachmentId/preview',
|
||||
|
||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||
if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) {
|
||||
// File is served by express.static middleware, redirect to the storage URL
|
||||
res.redirect(fileInfo.storageUrl);
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, '');
|
||||
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;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const path = require('path');
|
||||
const { UPLOAD_DIR } = require('../config/storage');
|
||||
const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath)
|
||||
? path.join(UPLOAD_DIR, fileInfo.filePath)
|
||||
: fileInfo.filePath;
|
||||
@ -403,15 +680,34 @@ router.get('/work-notes/attachments/:attachmentId/download',
|
||||
|
||||
// Local file handling - check if storageUrl is a local path (starts with /uploads/)
|
||||
if (fileInfo.storageUrl && fileInfo.storageUrl.startsWith('/uploads/')) {
|
||||
// File is served by express.static middleware, redirect to the storage URL
|
||||
res.redirect(fileInfo.storageUrl);
|
||||
// Extract relative path from storageUrl (remove /uploads/ prefix)
|
||||
const relativePath = fileInfo.storageUrl.replace(/^\/uploads\//, '');
|
||||
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;
|
||||
}
|
||||
|
||||
// Legacy local file handling (absolute path stored in filePath)
|
||||
// Resolve relative path if needed
|
||||
const path = require('path');
|
||||
const { UPLOAD_DIR } = require('../config/storage');
|
||||
const absolutePath = fileInfo.filePath && !path.isAbsolute(fileInfo.filePath)
|
||||
? path.join(UPLOAD_DIR, fileInfo.filePath)
|
||||
: fileInfo.filePath;
|
||||
@ -578,4 +874,19 @@ router.get('/:id/pause',
|
||||
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;
|
||||
|
||||
16
src/routes/workflowTemplate.routes.ts
Normal file
16
src/routes/workflowTemplate.routes.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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;
|
||||
@ -11,8 +11,8 @@
|
||||
*/
|
||||
|
||||
import { Client } from 'pg';
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryTypes } from 'sequelize';
|
||||
import { initializeGoogleSecretManager } from '../services/googleSecretManager.service';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import dotenv from 'dotenv';
|
||||
@ -21,14 +21,15 @@ import path from 'path';
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
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';
|
||||
// DB constants moved inside functions to ensure secrets are loaded first
|
||||
|
||||
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({
|
||||
host: DB_HOST,
|
||||
port: DB_PORT,
|
||||
@ -133,6 +134,10 @@ async function runMigrations(): Promise<void> {
|
||||
const m38 = require('../migrations/20251213-create-claim-invoice-credit-note-tables');
|
||||
const m39 = require('../migrations/20251214-create-dealer-completion-expenses');
|
||||
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 = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -178,8 +183,14 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
|
||||
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
|
||||
{ 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();
|
||||
|
||||
// Ensure migrations tracking table exists
|
||||
@ -195,10 +206,10 @@ async function runMigrations(): Promise<void> {
|
||||
}
|
||||
|
||||
// Get already executed migrations
|
||||
const executedResults = await sequelize.query<{ name: string }>(
|
||||
const executedResults = await sequelize.query(
|
||||
'SELECT name FROM migrations ORDER BY id',
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
) as { name: string }[];
|
||||
const executedMigrations = executedResults.map(r => r.name);
|
||||
|
||||
// Find pending migrations
|
||||
@ -246,6 +257,7 @@ async function runMigrations(): Promise<void> {
|
||||
async function testConnection(): Promise<void> {
|
||||
try {
|
||||
console.log('🔌 Testing database connection...');
|
||||
const { sequelize } = require('../config/database');
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection established!');
|
||||
} catch (error: any) {
|
||||
@ -260,6 +272,10 @@ async function autoSetup(): Promise<void> {
|
||||
console.log('========================================\n');
|
||||
|
||||
try {
|
||||
// Step 0: Initialize secrets
|
||||
console.log('🔐 Initializing secrets...');
|
||||
await initializeGoogleSecretManager();
|
||||
|
||||
// Step 1: Check and create database if needed
|
||||
const wasCreated = await checkAndCreateDatabase();
|
||||
|
||||
@ -273,6 +289,10 @@ async function autoSetup(): Promise<void> {
|
||||
console.log('✅ Setup completed successfully!');
|
||||
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');
|
||||
|
||||
if (wasCreated) {
|
||||
|
||||
19
src/scripts/check-db-schema.ts
Normal file
19
src/scripts/check-db-schema.ts
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
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();
|
||||
31
src/scripts/force-fix-schema.ts
Normal file
31
src/scripts/force-fix-schema.ts
Normal file
@ -0,0 +1,31 @@
|
||||
|
||||
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();
|
||||
@ -1,5 +1,5 @@
|
||||
import { sequelize } from '../config/database';
|
||||
import { QueryInterface, QueryTypes } from 'sequelize';
|
||||
import { initializeGoogleSecretManager } from '../services/googleSecretManager.service';
|
||||
import * as m0 from '../migrations/2025103000-create-users';
|
||||
import * as m1 from '../migrations/2025103001-create-workflow-requests';
|
||||
import * as m2 from '../migrations/2025103002-create-approval-levels';
|
||||
@ -43,6 +43,10 @@ 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 m39 from '../migrations/20251214-create-dealer-completion-expenses';
|
||||
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 {
|
||||
name: string;
|
||||
@ -100,6 +104,10 @@ const migrations: Migration[] = [
|
||||
{ name: '20251213-create-claim-invoice-credit-note-tables', module: m38 },
|
||||
{ name: '20251214-create-dealer-completion-expenses', module: m39 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
/**
|
||||
@ -128,12 +136,12 @@ async function ensureMigrationsTable(queryInterface: QueryInterface): Promise<vo
|
||||
/**
|
||||
* Get list of already executed migrations
|
||||
*/
|
||||
async function getExecutedMigrations(): Promise<string[]> {
|
||||
async function getExecutedMigrations(sequelize: any): Promise<string[]> {
|
||||
try {
|
||||
const results = await sequelize.query<{ name: string }>(
|
||||
const results = await sequelize.query(
|
||||
'SELECT name FROM migrations ORDER BY id',
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
) as { name: string }[];
|
||||
return results.map(r => r.name);
|
||||
} catch (error) {
|
||||
// Table might not exist yet
|
||||
@ -144,7 +152,7 @@ async function getExecutedMigrations(): Promise<string[]> {
|
||||
/**
|
||||
* Mark migration as executed
|
||||
*/
|
||||
async function markMigrationExecuted(name: string): Promise<void> {
|
||||
async function markMigrationExecuted(sequelize: any, name: string): Promise<void> {
|
||||
await sequelize.query(
|
||||
'INSERT INTO migrations (name) VALUES (:name) ON CONFLICT (name) DO NOTHING',
|
||||
{
|
||||
@ -159,6 +167,12 @@ async function markMigrationExecuted(name: string): Promise<void> {
|
||||
*/
|
||||
async function run() {
|
||||
try {
|
||||
console.log('🔐 Initializing secrets...');
|
||||
await initializeGoogleSecretManager();
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
const { sequelize } = require('../config/database');
|
||||
|
||||
await sequelize.authenticate();
|
||||
|
||||
const queryInterface = sequelize.getQueryInterface();
|
||||
@ -167,7 +181,7 @@ async function run() {
|
||||
await ensureMigrationsTable(queryInterface);
|
||||
|
||||
// Get already executed migrations
|
||||
const executedMigrations = await getExecutedMigrations();
|
||||
const executedMigrations = await getExecutedMigrations(sequelize);
|
||||
|
||||
// Find pending migrations
|
||||
const pendingMigrations = migrations.filter(
|
||||
@ -182,11 +196,12 @@ async function run() {
|
||||
|
||||
console.log(`🔄 Running ${pendingMigrations.length} migration(s)...`);
|
||||
|
||||
|
||||
// Run each pending migration
|
||||
for (const migration of pendingMigrations) {
|
||||
try {
|
||||
await migration.module.up(queryInterface);
|
||||
await markMigrationExecuted(migration.name);
|
||||
await markMigrationExecuted(sequelize, migration.name);
|
||||
console.log(`✅ ${migration.name}`);
|
||||
} catch (error: any) {
|
||||
console.error(`❌ Migration failed: ${migration.name} - ${error.message}`);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user