added the activity type in admin setting and additional approver on approved request modified to restart the TAT

This commit is contained in:
laxmanhalaki 2026-01-07 19:04:41 +05:30
parent 5ee206e493
commit 743f90d1a9
24 changed files with 1109 additions and 204 deletions

View File

@ -1,2 +1,2 @@
import{a as s}from"./index-D-gT3GUO.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.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}; import{a as s}from"./index-BtWUMn8R.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DHm03ykU.js";import"./ui-vendor-CdaLA-IN.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-AvM4PHvP.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-CfJIPDrI.js.map //# sourceMappingURL=conclusionApi-t9LwHY2s.js.map

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -52,7 +52,7 @@
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-D-gT3GUO.js"></script> <script type="module" crossorigin src="/assets/index-BtWUMn8R.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js"> <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/radix-vendor-DIkYAdWy.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js"> <link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
@ -60,7 +60,7 @@
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js"> <link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js"> <link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-AvM4PHvP.js"> <link rel="modulepreload" crossorigin href="/assets/router-vendor-AvM4PHvP.js">
<link rel="stylesheet" crossorigin href="/assets/index-DWmoW2h6.css"> <link rel="stylesheet" crossorigin href="/assets/index-Cki_huzr.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -15,15 +15,16 @@
## Overview ## Overview
The AI Conclusion Remark Generation feature automatically generates professional, context-aware conclusion remarks for workflow requests that have been approved or rejected. This feature uses 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 ### 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 - **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 - **Automatic Generation**: Can be triggered automatically when a request is approved/rejected
- **Manual Generation**: Users can regenerate conclusions on demand - **Manual Generation**: Users can regenerate conclusions on demand
- **Editable**: Generated remarks can be edited before finalization - **Editable**: Generated remarks can be edited before finalization
- **Enterprise Security**: Uses same service account credentials as Google Cloud Storage
### Use Cases ### Use Cases
1. **Automatic Generation**: When the final approver approves/rejects a request, an AI conclusion is generated in the background 1. **Automatic Generation**: When the final approver approves/rejects a request, an AI conclusion is generated in the background
@ -74,10 +75,10 @@ The AI Conclusion Remark Generation feature automatically generates professional
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────────┐ │
│ │ AI Providers (Claude/OpenAI/Gemini) │ │ │ │ Vertex AI Gemini (Google Cloud) │ │
│ │ - ClaudeProvider │ │ │ │ - VertexAI Client │ │
│ │ - OpenAIProvider │ │ │ │ - Service Account Authentication │ │
│ │ - GeminiProvider │ │ │ │ - Gemini Models (gemini-2.5-flash, etc.) │ │
│ └──────────────────────────────────────────────────────┘ │ │ └──────────────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
@ -114,22 +115,18 @@ The AI Conclusion Remark Generation feature automatically generates professional
### Environment Variables ### Environment Variables
```bash ```bash
# AI Provider Selection (claude, openai, gemini) # Google Cloud Configuration (required - same as GCS)
AI_PROVIDER=claude GCP_PROJECT_ID=re-platform-workflow-dealer
GCP_KEY_FILE=./credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
# Claude Configuration # Vertex AI Configuration (optional - defaults provided)
CLAUDE_API_KEY=your_claude_api_key VERTEX_AI_MODEL=gemini-2.5-flash
CLAUDE_MODEL=claude-sonnet-4-20250514 VERTEX_AI_LOCATION=asia-south1
AI_ENABLED=true
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key
OPENAI_MODEL=gpt-4o
# Gemini Configuration
GEMINI_API_KEY=your_gemini_api_key
GEMINI_MODEL=gemini-2.0-flash-lite
``` ```
**Note**: The service account key file is the same one used for Google Cloud Storage, ensuring consistent authentication across services.
### Admin Configuration (Database) ### Admin Configuration (Database)
The system reads configuration from the `system_config` table. Key settings: The system reads configuration from the `system_config` table. Key settings:
@ -138,21 +135,29 @@ The system reads configuration from the `system_config` table. Key settings:
|------------|---------|-------------| |------------|---------|-------------|
| `AI_ENABLED` | `true` | Enable/disable all AI features | | `AI_ENABLED` | `true` | Enable/disable all AI features |
| `AI_REMARK_GENERATION_ENABLED` | `true` | Enable/disable conclusion generation | | `AI_REMARK_GENERATION_ENABLED` | `true` | Enable/disable conclusion generation |
| `AI_PROVIDER` | `claude` | Preferred AI provider (claude, openai, gemini) |
| `AI_MAX_REMARK_LENGTH` | `2000` | Maximum characters for generated remarks | | `AI_MAX_REMARK_LENGTH` | `2000` | Maximum characters for generated remarks |
| `CLAUDE_API_KEY` | - | Claude API key (if using Claude) | | `VERTEX_AI_MODEL` | `gemini-2.5-flash` | Vertex AI Gemini model name |
| `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 |
### Provider Priority ### Available Models
1. **Preferred Provider**: Set via `AI_PROVIDER` config | Model Name | Description | Use Case |
2. **Fallback Chain**: If preferred fails, tries: |------------|-------------|----------|
- Claude → OpenAI → Gemini | `gemini-2.5-flash` | Latest fast model (default) | General purpose, quick responses |
3. **Environment Fallback**: If database config fails, uses environment variables | `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, "confidence": 0.85,
"generatedAt": "2025-01-15T10:30:00Z", "generatedAt": "2025-01-15T10:30:00Z",
"provider": "Claude (Anthropic)" "provider": "Vertex AI (Gemini)"
} }
} }
``` ```
@ -254,7 +259,7 @@ Content-Type: application/json
"finalRemark": "Finalized text...", "finalRemark": "Finalized text...",
"isEdited": true, "isEdited": true,
"editCount": 2, "editCount": 2,
"aiModelUsed": "Claude (Anthropic)", "aiModelUsed": "Vertex AI (Gemini)",
"aiConfidenceScore": 0.85, "aiConfidenceScore": 0.85,
"keyDiscussionPoints": ["Point 1", "Point 2"], "keyDiscussionPoints": ["Point 1", "Point 2"],
"generatedAt": "2025-01-15T10:30:00Z", "generatedAt": "2025-01-15T10:30:00Z",
@ -324,9 +329,9 @@ interface ConclusionContext {
- Sets target word count based on `AI_MAX_REMARK_LENGTH` - Sets target word count based on `AI_MAX_REMARK_LENGTH`
3. **AI Generation**: 3. **AI Generation**:
- Sends prompt to selected AI provider - Sends prompt to Vertex AI Gemini
- Receives generated text - Receives generated text (up to 4096 tokens)
- Validates length (trims if exceeds max) - Preserves full AI response (no truncation)
- Extracts key points - Extracts key points
- Calculates confidence score - 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 4. **Tone Guidelines**: Emphasizes natural, professional, archival-quality writing
5. **Context Awareness**: Includes all relevant data (approvals, notes, documents, activities) 5. **Context Awareness**: Includes all relevant data (approvals, notes, documents, activities)
### Provider-Specific Settings ### Vertex AI Settings
| Provider | Model | Max Tokens | Temperature | Notes | | Setting | Value | Description |
|----------|-------|------------|-------------|-------| |---------|-------|-------------|
| Claude | claude-sonnet-4-20250514 | 2048 | 0.3 | Best for longer, detailed conclusions | | Model | `gemini-2.5-flash` (default) | Fast, efficient model for conclusion generation |
| OpenAI | gpt-4o | 1024 | 0.3 | Balanced performance | | Max Output Tokens | `4096` | Maximum tokens in response (technical limit) |
| Gemini | gemini-2.0-flash-lite | - | 0.3 | Fast and cost-effective | | 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** 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** 3. **Request Not Found**
``` ```
@ -453,10 +475,10 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
### Error Recovery ### Error Recovery
- **Automatic Fallback**: If preferred provider fails, system tries fallback providers
- **Graceful Degradation**: If AI generation fails, user can write conclusion manually - **Graceful Degradation**: If AI generation fails, user can write conclusion manually
- **Retry Logic**: Manual regeneration is always available - **Retry Logic**: Manual regeneration is always available
- **Logging**: All errors are logged with context for debugging - **Logging**: All errors are logged with context for debugging
- **Token Limit Handling**: If response hits token limit, full response is preserved (no truncation)
--- ---
@ -472,14 +494,17 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
### For Administrators ### For Administrators
1. **API Key Management**: Store API keys securely in database or environment variables 1. **Service Account Setup**:
2. **Provider Selection**: Choose provider based on: - Ensure service account key file exists and is accessible
- **Claude**: Best quality, higher cost - Verify service account has `Vertex AI User` role
- **OpenAI**: Balanced quality/cost - Use same credentials as Google Cloud Storage for consistency
- **Gemini**: Fast, cost-effective 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 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 5. **Testing**: Test with sample requests before enabling in production
6. **Region Selection**: Choose region closest to your deployment for lower latency
### For Users ### For Users
@ -499,8 +524,10 @@ Write a brief, professional conclusion (approximately X words, max Y characters)
**Diagnosis**: **Diagnosis**:
1. Check `AI_ENABLED` config value 1. Check `AI_ENABLED` config value
2. Check `AI_REMARK_GENERATION_ENABLED` config value 2. Check `AI_REMARK_GENERATION_ENABLED` config value
3. Verify API keys are configured 3. Verify service account key file exists and is accessible
4. Check provider initialization logs 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**: **Solution**:
```bash ```bash
@ -509,6 +536,14 @@ tail -f logs/app.log | grep "AI Service"
# Verify config # Verify config
SELECT * FROM system_config WHERE config_key LIKE 'AI_%'; SELECT * FROM system_config WHERE config_key LIKE 'AI_%';
# Verify service account key file
ls -la credentials/re-platform-workflow-dealer-3d5738fcc1f9.json
# Check environment variables
echo $GCP_PROJECT_ID
echo $GCP_KEY_FILE
echo $VERTEX_AI_MODEL
``` ```
### Issue: Generated Text Too Long/Short ### Issue: Generated Text Too Long/Short
@ -518,7 +553,8 @@ SELECT * FROM system_config WHERE config_key LIKE 'AI_%';
**Solution**: **Solution**:
1. Adjust `AI_MAX_REMARK_LENGTH` in admin config 1. Adjust `AI_MAX_REMARK_LENGTH` in admin config
2. Check prompt target word count calculation 2. Check prompt target word count calculation
3. 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 ### Issue: Poor Quality Conclusions
@ -527,37 +563,50 @@ SELECT * FROM system_config WHERE config_key LIKE 'AI_%';
**Solution**: **Solution**:
1. Verify context data is complete (approvals, notes, documents) 1. Verify context data is complete (approvals, notes, documents)
2. Check prompt includes all relevant information 2. Check prompt includes all relevant information
3. Try different provider (Claude generally produces better quality) 3. Try different model (e.g., `gemini-1.5-pro` for better quality)
4. Adjust temperature if needed (lower = more focused) 4. Temperature is set to 0.3 for focused output (can be adjusted in code if needed)
### Issue: Slow Generation ### Issue: Slow Generation
**Symptoms**: AI generation takes too long **Symptoms**: AI generation takes too long
**Solution**: **Solution**:
1. Check provider API status 1. Check Vertex AI API status in Google Cloud Console
2. Verify network connectivity 2. Verify network connectivity
3. Consider using faster provider (Gemini) 3. Consider using `gemini-2.5-flash` model (fastest option)
4. Check for rate limiting 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**: **Diagnosis**:
1. Check API key is valid 1. Check service account key file exists and is valid
2. Verify SDK package is installed 2. Verify `@google-cloud/vertexai` package is installed
3. Check environment variables 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**: **Solution**:
```bash ```bash
# Install missing SDK # Install missing SDK
npm install @anthropic-ai/sdk # For Claude npm install @google-cloud/vertexai
npm install openai # For OpenAI
npm install @google/generative-ai # For Gemini
# Verify API key # Verify service account key file
echo $CLAUDE_API_KEY # Should show key 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 ## Version History
- **v1.0.0** (2025-01-15): Initial implementation - **v2.0.0**: Vertex AI Migration
- Multi-provider support (Claude, OpenAI, Gemini) - Migrated to Google Cloud Vertex AI Gemini
- Automatic and manual generation - Service account authentication (same as GCS)
- TAT risk integration - Removed multi-provider support
- Key points extraction - Increased max output tokens to 4096
- Confidence scoring - 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` 1. Check logs: `logs/app.log`
2. Review admin configuration panel 2. Review admin configuration panel
3. Contact development team 3. Contact development team
4. Refer to provider documentation: 4. Refer to Vertex AI documentation:
- [Claude API Docs](https://docs.anthropic.com) - [Vertex AI Documentation](https://cloud.google.com/vertex-ai/docs)
- [OpenAI API Docs](https://platform.openai.com/docs) - [Gemini Models](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini)
- [Gemini API Docs](https://ai.google.dev/docs) - [Vertex AI Setup Guide](../VERTEX_AI_INTEGRATION.md)
--- ---
**Last Updated**: January 2025
**Maintained By**: Royal Enfield Development Team **Maintained By**: Royal Enfield Development Team
---
## Related Documentation
- [Vertex AI Integration Guide](./VERTEX_AI_INTEGRATION.md) - Detailed setup and migration information

View File

@ -1,6 +1,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Holiday, HolidayType } from '@models/Holiday'; import { Holiday, HolidayType } from '@models/Holiday';
import { holidayService } from '@services/holiday.service'; import { holidayService } from '@services/holiday.service';
import { activityTypeService } from '@services/activityType.service';
import { sequelize } from '@config/database'; import { sequelize } from '@config/database';
import { QueryTypes, Op } from 'sequelize'; import { QueryTypes, Op } from 'sequelize';
import logger from '@utils/logger'; import logger from '@utils/logger';
@ -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'
});
}
};

View 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');
}

127
src/models/ActivityType.ts Normal file
View 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 };

View File

@ -24,6 +24,7 @@ import { WorkflowTemplate } from './WorkflowTemplate';
import { InternalOrder } from './InternalOrder'; import { InternalOrder } from './InternalOrder';
import { ClaimBudgetTracking } from './ClaimBudgetTracking'; import { ClaimBudgetTracking } from './ClaimBudgetTracking';
import { Dealer } from './Dealer'; import { Dealer } from './Dealer';
import { ActivityType } from './ActivityType';
// Define associations // Define associations
const defineAssociations = () => { const defineAssociations = () => {
@ -168,7 +169,8 @@ export {
WorkflowTemplate, WorkflowTemplate,
InternalOrder, InternalOrder,
ClaimBudgetTracking, ClaimBudgetTracking,
Dealer Dealer,
ActivityType
}; };
// Export default sequelize instance // Export default sequelize instance

View File

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

View File

@ -1,6 +1,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { getPublicConfig } from '../config/system.config'; import { getPublicConfig } from '../config/system.config';
import { asyncHandler } from '../middlewares/errorHandler.middleware'; import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { activityTypeService } from '../services/activityType.service';
const router = Router(); const router = Router();
@ -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; export default router;

View File

@ -134,6 +134,7 @@ async function runMigrations(): Promise<void> {
const m39 = require('../migrations/20251214-create-dealer-completion-expenses'); const m39 = require('../migrations/20251214-create-dealer-completion-expenses');
const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns'); const m40 = require('../migrations/20251218-fix-claim-invoice-credit-note-columns');
const m41 = require('../migrations/20250120-create-dealers-table'); const m41 = require('../migrations/20250120-create-dealers-table');
const m42 = require('../migrations/20250125-create-activity-types');
const migrations = [ const migrations = [
{ name: '2025103000-create-users', module: m0 }, { name: '2025103000-create-users', module: m0 },
@ -180,6 +181,7 @@ async function runMigrations(): Promise<void> {
{ name: '20251214-create-dealer-completion-expenses', module: m39 }, { name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 }, { name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
]; ];
const queryInterface = sequelize.getQueryInterface(); const queryInterface = sequelize.getQueryInterface();

View File

@ -44,6 +44,7 @@ import * as m38 from '../migrations/20251213-create-claim-invoice-credit-note-ta
import * as m39 from '../migrations/20251214-create-dealer-completion-expenses'; import * as m39 from '../migrations/20251214-create-dealer-completion-expenses';
import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns'; import * as m40 from '../migrations/20251218-fix-claim-invoice-credit-note-columns';
import * as m41 from '../migrations/20250120-create-dealers-table'; import * as m41 from '../migrations/20250120-create-dealers-table';
import * as m42 from '../migrations/20250125-create-activity-types';
interface Migration { interface Migration {
name: string; name: string;
@ -102,6 +103,7 @@ const migrations: Migration[] = [
{ name: '20251214-create-dealer-completion-expenses', module: m39 }, { name: '20251214-create-dealer-completion-expenses', module: m39 },
{ name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 }, { name: '20251218-fix-claim-invoice-credit-note-columns', module: m40 },
{ name: '20250120-create-dealers-table', module: m41 }, { name: '20250120-create-dealers-table', module: m41 },
{ name: '20250125-create-activity-types', module: m42 },
]; ];
/** /**

View File

@ -7,6 +7,7 @@ import { logTatConfig } from './config/tat.config';
import { logSystemConfig } from './config/system.config'; import { logSystemConfig } from './config/system.config';
import { initializeHolidaysCache } from './utils/tatTimeUtils'; import { initializeHolidaysCache } from './utils/tatTimeUtils';
import { seedDefaultConfigurations } from './services/configSeed.service'; import { seedDefaultConfigurations } from './services/configSeed.service';
import { seedDefaultActivityTypes } from './services/activityTypeSeed.service';
import { startPauseResumeJob } from './jobs/pauseResumeJob'; import { startPauseResumeJob } from './jobs/pauseResumeJob';
import './queues/pauseResumeWorker'; // Initialize pause resume worker import './queues/pauseResumeWorker'; // Initialize pause resume worker
import { initializeQueueMetrics, stopQueueMetrics } from './utils/queueMetrics'; import { initializeQueueMetrics, stopQueueMetrics } from './utils/queueMetrics';
@ -40,6 +41,13 @@ const startServer = async (): Promise<void> => {
console.error('⚠️ Configuration seeding error:', error); console.error('⚠️ Configuration seeding error:', error);
} }
// Seed default activity types if table is empty
try {
await seedDefaultActivityTypes();
} catch (error) {
console.error('⚠️ Activity type seeding error:', error);
}
// Initialize holidays cache for TAT calculations // Initialize holidays cache for TAT calculations
try { try {
await initializeHolidaysCache(); await initializeHolidaysCache();

View File

@ -0,0 +1,169 @@
import { ActivityType } from '@models/ActivityType';
import { Op } from 'sequelize';
import logger from '@utils/logger';
export class ActivityTypeService {
/**
* Get all activity types (optionally filtered by active status)
*/
async getAllActivityTypes(activeOnly: boolean = false): Promise<ActivityType[]> {
try {
const where: any = {};
if (activeOnly) {
where.isActive = true;
}
const activityTypes = await ActivityType.findAll({
where,
order: [['title', 'ASC']],
include: [
{
association: 'creator',
attributes: ['userId', 'email', 'displayName', 'firstName', 'lastName']
},
{
association: 'updater',
attributes: ['userId', 'email', 'displayName', 'firstName', 'lastName']
}
]
});
return activityTypes;
} catch (error) {
logger.error('[ActivityType Service] Error fetching activity types:', error);
throw error;
}
}
/**
* Get a single activity type by ID
*/
async getActivityTypeById(activityTypeId: string): Promise<ActivityType | null> {
try {
const activityType = await ActivityType.findByPk(activityTypeId, {
include: [
{
association: 'creator',
attributes: ['userId', 'email', 'displayName', 'firstName', 'lastName']
},
{
association: 'updater',
attributes: ['userId', 'email', 'displayName', 'firstName', 'lastName']
}
]
});
return activityType;
} catch (error) {
logger.error('[ActivityType Service] Error fetching activity type:', error);
throw error;
}
}
/**
* Create a new activity type
*/
async createActivityType(activityTypeData: {
title: string;
itemCode?: string;
taxationType?: string;
sapRefNo?: string;
createdBy: string;
}): Promise<ActivityType> {
try {
// Check if title already exists
const existing = await ActivityType.findOne({
where: {
title: activityTypeData.title,
isActive: true
}
});
if (existing) {
throw new Error(`Activity type with title "${activityTypeData.title}" already exists`);
}
const activityType = await ActivityType.create({
...activityTypeData,
isActive: true
} as any);
logger.info(`[ActivityType Service] Activity type created: ${activityTypeData.title}`);
return activityType;
} catch (error) {
logger.error('[ActivityType Service] Error creating activity type:', error);
throw error;
}
}
/**
* Update an activity type
*/
async updateActivityType(activityTypeId: string, updates: {
title?: string;
itemCode?: string;
taxationType?: string;
sapRefNo?: string;
isActive?: boolean;
}, updatedBy: string): Promise<ActivityType | null> {
try {
const activityType = await ActivityType.findByPk(activityTypeId);
if (!activityType) {
return null;
}
// If title is being updated, check for duplicates
if (updates.title && updates.title !== activityType.title) {
const existing = await ActivityType.findOne({
where: {
title: updates.title,
activityTypeId: { [Op.ne]: activityTypeId },
isActive: true
}
});
if (existing) {
throw new Error(`Activity type with title "${updates.title}" already exists`);
}
}
await activityType.update({
...updates,
updatedBy
} as any);
logger.info(`[ActivityType Service] Activity type updated: ${activityTypeId}`);
return activityType.reload();
} catch (error) {
logger.error('[ActivityType Service] Error updating activity type:', error);
throw error;
}
}
/**
* Delete (deactivate) an activity type
*/
async deleteActivityType(activityTypeId: string): Promise<void> {
try {
const activityType = await ActivityType.findByPk(activityTypeId);
if (!activityType) {
throw new Error('Activity type not found');
}
// Soft delete by setting isActive to false
await activityType.update({
isActive: false
} as any);
logger.info(`[ActivityType Service] Activity type deactivated: ${activityTypeId}`);
} catch (error) {
logger.error('[ActivityType Service] Error deleting activity type:', error);
throw error;
}
}
}
export const activityTypeService = new ActivityTypeService();

View File

@ -0,0 +1,146 @@
import { sequelize } from '@config/database';
import { QueryTypes } from 'sequelize';
import logger from '@utils/logger';
import { ActivityType } from '@models/ActivityType';
/**
* Default activity types from CLAIM_TYPES array
* These will be seeded into the database with default item_code values (1-13)
*/
const DEFAULT_ACTIVITY_TYPES = [
{ title: 'Riders Mania Claims', itemCode: '1' },
{ title: 'Marketing Cost Bike to Vendor', itemCode: '2' },
{ title: 'Media Bike Service', itemCode: '3' },
{ title: 'ARAI Motorcycle Liquidation', itemCode: '4' },
{ title: 'ARAI Certification STA Approval CNR', itemCode: '5' },
{ title: 'Procurement of Spares/Apparel/GMA for Events', itemCode: '6' },
{ title: 'Fuel for Media Bike Used for Event', itemCode: '7' },
{ title: 'Motorcycle Buyback and Goodwill Support', itemCode: '8' },
{ title: 'Liquidation of Used Motorcycle', itemCode: '9' },
{ title: 'Motorcycle Registration CNR (Owned or Gifted by RE)', itemCode: '10' },
{ title: 'Legal Claims Reimbursement', itemCode: '11' },
{ title: 'Service Camp Claims', itemCode: '12' },
{ title: 'Corporate Claims Institutional Sales PDI', itemCode: '13' }
];
/**
* Seed default activity types if table is empty
* Called automatically on server startup
*/
export async function seedDefaultActivityTypes(): Promise<void> {
try {
// Check if activity_types table exists
const tableExists = await sequelize.query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'activity_types'
)`,
{ type: QueryTypes.SELECT }
);
const exists = tableExists && tableExists.length > 0 && (tableExists[0] as any).exists;
if (!exists) {
logger.warn('[ActivityType Seed] ⚠️ activity_types table does not exist. Please run migrations first (npm run migrate). Skipping seed.');
return;
}
logger.info('[ActivityType Seed] Seeding default activity types (duplicates will be skipped automatically)...');
// Get system user ID (first admin user) for created_by
const systemUser = await sequelize.query(
`SELECT user_id FROM users WHERE role = 'ADMIN' ORDER BY created_at ASC LIMIT 1`,
{ type: QueryTypes.SELECT }
);
let systemUserId: string | null = null;
if (systemUser && systemUser.length > 0) {
systemUserId = (systemUser[0] as any).user_id;
}
if (!systemUserId) {
logger.warn('[ActivityType Seed] No admin user found. Activity types will be created without created_by reference.');
// Use a placeholder UUID - this should not happen in production
systemUserId = '00000000-0000-0000-0000-000000000000';
}
// Insert default activity types with proper handling
let createdCount = 0;
let updatedCount = 0;
let skippedCount = 0;
for (const activityType of DEFAULT_ACTIVITY_TYPES) {
const { title, itemCode } = activityType;
try {
// Check if activity type already exists (active or inactive)
const existing = await ActivityType.findOne({
where: { title }
});
if (existing) {
// If exists but inactive, reactivate it
if (!existing.isActive) {
// Update item_code if it's null (preserve if user has already set it)
const updateData: any = {
isActive: true,
updatedBy: systemUserId
};
// Only set item_code if it's currently null (don't overwrite user edits)
if (!existing.itemCode) {
updateData.itemCode = itemCode;
}
await existing.update(updateData);
updatedCount++;
logger.debug(`[ActivityType Seed] Reactivated existing activity type: ${title}${!existing.itemCode ? ` (set item_code: ${itemCode})` : ''}`);
} else {
// Already exists and active
// Update item_code if it's null (preserve if user has already set it)
if (!existing.itemCode) {
await existing.update({
itemCode: itemCode,
updatedBy: systemUserId
} as any);
logger.debug(`[ActivityType Seed] Updated item_code for existing activity type: ${title} (${itemCode})`);
}
skippedCount++;
logger.debug(`[ActivityType Seed] Activity type already exists and active: ${title}`);
}
} else {
// Create new activity type with default item_code
await ActivityType.create({
title,
itemCode: itemCode,
taxationType: null,
sapRefNo: null,
isActive: true,
createdBy: systemUserId
} as any);
createdCount++;
logger.debug(`[ActivityType Seed] Created new activity type: ${title} (item_code: ${itemCode})`);
}
} catch (error: any) {
// Log error but continue with other activity types
logger.warn(`[ActivityType Seed] Error processing ${title}: ${error?.message || String(error)}`);
skippedCount++;
}
}
// Verify how many are now active
const result = await sequelize.query(
'SELECT COUNT(*) as count FROM activity_types WHERE is_active = true',
{ type: QueryTypes.SELECT }
);
const totalCount = result && (result[0] as any).count ? (result[0] as any).count : 0;
logger.info(`[ActivityType Seed] ✅ Activity type seeding complete. Created: ${createdCount}, Reactivated: ${updatedCount}, Skipped: ${skippedCount}, Total active: ${totalCount}`);
} catch (error: any) {
logger.error('[ActivityType Seed] ❌ Error seeding activity types:', {
message: error?.message || String(error),
stack: error?.stack,
name: error?.name
});
// Don't throw - let server start even if seeding fails
}
}

View File

@ -120,7 +120,18 @@ export class ApprovalService {
// Handle approval - move to next level or close workflow (wf already loaded above) // Handle approval - move to next level or close workflow (wf already loaded above)
if (action.action === 'APPROVE') { if (action.action === 'APPROVE') {
if (level.isFinalApprover) { // Check if this is final approval: either isFinalApprover flag is set OR all levels are approved
// This handles cases where additional approvers are added after initial approval
const allLevels = await ApprovalLevel.findAll({
where: { requestId: level.requestId },
order: [['levelNumber', 'ASC']]
});
const approvedLevelsCount = allLevels.filter((l: any) => l.status === 'APPROVED').length;
const totalLevels = allLevels.length;
const isAllLevelsApproved = approvedLevelsCount === totalLevels;
const isFinalApproval = level.isFinalApprover || isAllLevelsApproved;
if (isFinalApproval) {
// Final approver - close workflow as APPROVED // Final approver - close workflow as APPROVED
await WorkflowRequest.update( await WorkflowRequest.update(
{ {
@ -134,6 +145,7 @@ export class ApprovalService {
level: level.levelNumber, level: level.levelNumber,
isFinalApproval: true, isFinalApproval: true,
status: 'APPROVED', status: 'APPROVED',
detectedBy: level.isFinalApprover ? 'isFinalApprover flag' : 'all levels approved check'
}); });
// Log final approval activity first (so it's included in AI context) // Log final approval activity first (so it's included in AI context)
@ -234,7 +246,41 @@ export class ApprovalService {
const aiResult = await aiService.generateConclusionRemark(context); const aiResult = await aiService.generateConclusionRemark(context);
// Save to database // Check if conclusion already exists (e.g., from previous final approval before additional approver was added)
const existingConclusion = await ConclusionRemark.findOne({
where: { requestId: level.requestId }
});
if (existingConclusion) {
// Update existing conclusion with new AI-generated remark (regenerated with updated context)
await existingConclusion.update({
aiGeneratedRemark: aiResult.remark,
aiModelUsed: aiResult.provider,
aiConfidenceScore: aiResult.confidence,
// Preserve finalRemark if it was already finalized
// Only reset if it wasn't finalized yet
finalRemark: (existingConclusion as any).finalizedAt ? (existingConclusion as any).finalRemark : null,
editedBy: null,
isEdited: false,
editCount: 0,
approvalSummary: {
totalLevels: approvalLevels.length,
approvedLevels: approvalLevels.filter((l: any) => l.status === 'APPROVED').length,
averageTatUsage: approvalLevels.reduce((sum: number, l: any) =>
sum + Number(l.tatPercentageUsed || 0), 0) / (approvalLevels.length || 1)
},
documentSummary: {
totalDocuments: documents.length,
documentNames: documents.map((d: any) => d.originalFileName || d.fileName)
},
keyDiscussionPoints: aiResult.keyPoints,
generatedAt: new Date(),
// Preserve finalizedAt if it was already finalized
finalizedAt: (existingConclusion as any).finalizedAt || null
} as any);
logger.info(`[Approval] Updated existing AI conclusion for request ${level.requestId} with regenerated content (includes new approver)`);
} else {
// Create new conclusion
await ConclusionRemark.create({ await ConclusionRemark.create({
requestId: level.requestId, requestId: level.requestId,
aiGeneratedRemark: aiResult.remark, aiGeneratedRemark: aiResult.remark,
@ -258,6 +304,7 @@ export class ApprovalService {
generatedAt: new Date(), generatedAt: new Date(),
finalizedAt: null finalizedAt: null
} as any); } as any);
}
logAIEvent('response', { logAIEvent('response', {
requestId: level.requestId, requestId: level.requestId,

View File

@ -315,7 +315,10 @@ class NotificationService {
if (!emailType) { if (!emailType) {
// This notification type doesn't warrant email // This notification type doesn't warrant email
// Note: 'document_added' emails are handled separately via emailNotificationService
if (payload.type !== 'document_added') {
console.log(`[DEBUG Email] No email for notification type: ${payload.type}`); console.log(`[DEBUG Email] No email for notification type: ${payload.type}`);
}
return; return;
} }

View File

@ -402,10 +402,15 @@ export class WorkflowService {
levelName = `Additional Approver - ${userName}`; levelName = `Additional Approver - ${userName}`;
} }
// Check if request is currently APPROVED - if so, we need to reactivate it
const workflowStatus = (workflow as any).status;
const isRequestApproved = workflowStatus === 'APPROVED' || workflowStatus === WorkflowStatus.APPROVED;
// Determine if the new level should be IN_PROGRESS // Determine if the new level should be IN_PROGRESS
// If we're adding at the current level, the new approver becomes the active approver // If we're adding at the current level OR request was approved, the new approver becomes the active approver
const workflowCurrentLevel = (workflow as any).currentLevel; const workflowCurrentLevel = (workflow as any).currentLevel;
const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel; const isAddingAtCurrentLevel = targetLevel === workflowCurrentLevel;
const shouldBeActive = isAddingAtCurrentLevel || isRequestApproved;
// Create new approval level at target position // Create new approval level at target position
const newLevel = await ApprovalLevel.create({ const newLevel = await ApprovalLevel.create({
@ -417,16 +422,28 @@ export class WorkflowService {
approverName: userName, approverName: userName,
tatHours, tatHours,
// tatDays is auto-calculated by database as a generated column // tatDays is auto-calculated by database as a generated column
status: isAddingAtCurrentLevel ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING, status: shouldBeActive ? ApprovalStatus.IN_PROGRESS : ApprovalStatus.PENDING,
isFinalApprover: targetLevel === allLevels.length + 1, isFinalApprover: targetLevel === allLevels.length + 1,
levelStartTime: isAddingAtCurrentLevel ? new Date() : null, levelStartTime: shouldBeActive ? new Date() : null,
tatStartTime: isAddingAtCurrentLevel ? new Date() : null tatStartTime: shouldBeActive ? new Date() : null
} as any); } as any);
// IMPORTANT: If we're adding at the current level, the workflow's currentLevel stays the same // If request was APPROVED and we're adding a new approver, reactivate the request
if (isRequestApproved) {
// Change request status back to PENDING
await workflow.update({
status: WorkflowStatus.PENDING,
currentLevel: targetLevel // Set new approver as current level
} as any);
logger.info(`[Workflow] Request ${requestId} status changed from APPROVED to PENDING - new approver added at level ${targetLevel}`);
} else if (isAddingAtCurrentLevel) {
// If we're adding at the current level, the workflow's currentLevel stays the same
// (it's still the same level number, just with a new approver) // (it's still the same level number, just with a new approver)
// The status update we did above ensures the shifted level becomes PENDING
// No need to update workflow.currentLevel - it's already correct // No need to update workflow.currentLevel - it's already correct
} else {
// If adding after current level, update currentLevel to the new approver
await workflow.update({ currentLevel: targetLevel } as any);
}
// Update isFinalApprover for previous final approver (now it's not final anymore) // Update isFinalApprover for previous final approver (now it's not final anymore)
if (allLevels.length > 0) { if (allLevels.length > 0) {
@ -451,8 +468,8 @@ export class WorkflowService {
isActive: true isActive: true
} as any); } as any);
// If new approver is at current level, schedule TAT jobs // Schedule TAT jobs if new approver is active (either at current level or request was approved)
if (targetLevel === (workflow as any).currentLevel) { if (shouldBeActive) {
const workflowPriority = (workflow as any)?.priority || 'STANDARD'; const workflowPriority = (workflow as any)?.priority || 'STANDARD';
await tatSchedulerService.scheduleTatJobs( await tatSchedulerService.scheduleTatJobs(
requestId, requestId,
@ -462,6 +479,7 @@ export class WorkflowService {
new Date(), new Date(),
workflowPriority workflowPriority
); );
logger.info(`[Workflow] TAT jobs scheduled for new approver at level ${targetLevel} (request was ${isRequestApproved ? 'APPROVED - reactivated' : 'active'})`);
} }
// Get the user who is adding the approver // Get the user who is adding the approver