made some enhancement on dahboard with given changes

This commit is contained in:
laxmanhalaki 2025-11-21 18:27:41 +05:30
parent dcb53a89ed
commit 4da78f9b40
39 changed files with 1754 additions and 73 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1 @@
{"version":3,"file":"conclusionApi-LySNBiVn.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":"0PAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAKA,eAAsBC,EAAcJ,EAA8C,CAEhF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

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

66
build/index.html Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
<meta name="theme-color" content="#2d4a3e" />
<title>Royal Enfield | Approval Portal</title>
<!-- Preload critical fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Ensure proper icon rendering and layout -->
<style>
/* Ensure Lucide icons render properly */
svg {
display: inline-block;
vertical-align: middle;
}
/* Fix for icon alignment in buttons */
button svg {
flex-shrink: 0;
}
/* Ensure proper text rendering */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* Fix for mobile viewport and sidebar */
@media (max-width: 768px) {
html {
overflow-x: hidden;
}
}
/* Ensure proper sidebar toggle behavior */
.sidebar-toggle {
transition: all 0.3s ease-in-out;
}
/* Fix for icon button hover states */
button:hover svg {
transform: scale(1.05);
transition: transform 0.2s ease;
}
</style>
<script type="module" crossorigin src="/assets/index-DRwsycIY.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Cji9-Yri.js">
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CbkudDDo.js">
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DHm03ykU.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-i7LKlA3D.js">
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
<link rel="modulepreload" crossorigin href="/assets/router-vendor-1fSSvDCY.js">
<link rel="stylesheet" crossorigin href="/assets/index-DI8aVCLa.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 88 KiB

26
build/service-worker.js Normal file
View File

@ -0,0 +1,26 @@
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
const title = data.title || 'Notification';
console.log('notification dat i recive', data);
const rawUrl = data.url || (data.requestNumber ? `/request/${data.requestNumber}` : '/');
const absoluteUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : (self.location.origin + rawUrl);
const options = {
body: data.body || 'New message',
icon: '/royal_enfield_logo.png',
badge: '/royal_enfield_logo.png',
data: { url: absoluteUrl }
};
console.log('options', options);
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const targetUrl = (event.notification && event.notification.data && event.notification.data.url) || (self.location.origin + '/');
event.waitUntil((async () => {
// Always open a new window/tab to ensure SPA router picks up the correct path
if (clients.openWindow) return clients.openWindow(targetUrl);
})());
});

2
build/vite.svg Normal file
View File

@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -38,8 +38,16 @@ SMTP_USER=notifications@royalenfield.com
SMTP_PASSWORD=your_smtp_password
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
# AI Service (for conclusion generation) mandatory for claude
# AI Service (for conclusion generation)
# Note: API keys are configured in the admin panel (database), not in environment variables
# AI Provider: claude, openai, or gemini
AI_PROVIDER=claude
# AI Model Configuration (optional - defaults used if not set)
# These can be overridden via environment variables or admin panel
CLAUDE_MODEL=claude-sonnet-4-20250514
OPENAI_MODEL=gpt-4o
GEMINI_MODEL=gemini-2.0-flash-lite
# Logging
LOG_LEVEL=info

View File

@ -66,7 +66,7 @@ app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Logging middleware
app.use(morgan('combined'));
// Health check endpoint
// Health check endpoint (before API routes)
app.get('/health', (_req: express.Request, res: express.Response) => {
res.status(200).json({
status: 'OK',
@ -76,23 +76,13 @@ app.get('/health', (_req: express.Request, res: express.Response) => {
});
});
// Mount API routes
// Mount API routes - MUST be before static file serving
app.use('/api/v1', routes);
// Serve uploaded files statically
ensureUploadDir();
app.use('/uploads', express.static(UPLOAD_DIR));
// Root endpoint
app.get('/', (_req: express.Request, res: express.Response) => {
res.status(200).json({
message: 'Royal Enfield Workflow Management System API',
version: '1.0.0',
status: 'running',
timestamp: new Date()
});
});
// Legacy SSO Callback endpoint for user creation/update (kept for backward compatibility)
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
try {
@ -183,13 +173,62 @@ app.get('/api/v1/users', async (_req: express.Request, res: express.Response): P
}
});
// Error handling middleware
app.use((req: express.Request, res: express.Response) => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`,
timestamp: new Date(),
// Serve React build static files (only in production or when build folder exists)
// Check for both 'build' (Create React App) and 'dist' (Vite) folders
const buildPath = path.join(__dirname, "..", "build");
const distPath = path.join(__dirname, "..", "dist");
const fs = require('fs');
// Try to find React build directory
let reactBuildPath: string | null = null;
if (fs.existsSync(buildPath)) {
reactBuildPath = buildPath;
} else if (fs.existsSync(distPath)) {
reactBuildPath = distPath;
}
// Serve static files if React build exists
if (reactBuildPath && fs.existsSync(path.join(reactBuildPath, "index.html"))) {
// Serve static assets (JS, CSS, images, etc.)
app.use(express.static(reactBuildPath));
// Catch-all handler: serve React app for all non-API routes
// This must be AFTER all API routes to avoid intercepting API requests
app.get('*', (req: express.Request, res: express.Response): void => {
// Don't serve React for API routes, uploads, or health check
if (req.path.startsWith('/api/') || req.path.startsWith('/uploads/') || req.path === '/health') {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`,
timestamp: new Date(),
});
return;
}
// Serve React app for all other routes (SPA routing)
// This handles client-side routing in React Router
res.sendFile(path.join(reactBuildPath!, "index.html"));
});
});
} else {
// No React build found - provide API info at root and use standard 404 handler
app.get('/', (_req: express.Request, res: express.Response): void => {
res.status(200).json({
message: 'Royal Enfield Workflow Management System API',
version: '1.0.0',
status: 'running',
timestamp: new Date(),
note: 'React build not found. API is available at /api/v1'
});
});
// Standard 404 handler for non-existent routes
app.use((req: express.Request, res: express.Response): void => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`,
timestamp: new Date(),
});
});
}
export default app;

View File

@ -240,6 +240,68 @@ export const bulkImportHolidays = async (req: Request, res: Response): Promise<v
}
};
/**
* Get public configurations (read-only, non-sensitive)
* Accessible to all authenticated users
*/
export const getPublicConfigurations = async (req: Request, res: Response): Promise<void> => {
try {
const { category } = req.query;
// Only allow certain categories for public access
const allowedCategories = ['DOCUMENT_POLICY', 'TAT_SETTINGS', 'WORKFLOW_SHARING'];
if (category && !allowedCategories.includes(category as string)) {
res.status(403).json({
success: false,
error: 'Access denied to this configuration category'
});
return;
}
let whereClause = '';
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`;
}
const rawConfigurations = await sequelize.query(`
SELECT
config_key,
config_category,
config_value,
value_type,
display_name,
description
FROM admin_configurations
${whereClause}
ORDER BY config_category, sort_order
`, { type: QueryTypes.SELECT });
// Map snake_case to camelCase for frontend
const configurations = (rawConfigurations as any[]).map((config: any) => ({
configKey: config.config_key,
configCategory: config.config_category,
configValue: config.config_value,
valueType: config.value_type,
displayName: config.display_name,
description: config.description
}));
res.json({
success: true,
data: configurations,
count: configurations.length
});
} catch (error) {
logger.error('[Admin] Error fetching public configurations:', error);
res.status(500).json({
success: false,
error: 'Failed to fetch configurations'
});
}
};
/**
* Get all admin configurations
*/
@ -371,7 +433,7 @@ export const updateConfiguration = async (req: Request, res: Response): Promise<
}
// If AI config was updated, reinitialize AI service
const aiConfigKeys = ['AI_PROVIDER', 'CLAUDE_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'AI_ENABLED'];
const aiConfigKeys = ['AI_PROVIDER', 'CLAUDE_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'CLAUDE_MODEL', 'OPENAI_MODEL', 'GEMINI_MODEL', 'AI_ENABLED'];
if (aiConfigKeys.includes(configKey)) {
try {
const { aiService } = require('../services/ai.service');

View File

@ -7,6 +7,7 @@ import { ResponseHandler } from '@utils/responseHandler';
import { activityService } from '@services/activity.service';
import type { AuthenticatedRequest } from '../types/express';
import { getRequestMetadata } from '@utils/requestUtils';
import { getConfigNumber, getConfigValue } from '@services/configReader.service';
export class DocumentController {
async upload(req: AuthenticatedRequest, res: Response): Promise<void> {
@ -29,6 +30,33 @@ export class DocumentController {
return;
}
// Validate file size against database configuration
const maxFileSizeMB = await getConfigNumber('MAX_FILE_SIZE_MB', 10);
const maxFileSizeBytes = maxFileSizeMB * 1024 * 1024;
if (file.size > maxFileSizeBytes) {
ResponseHandler.error(
res,
`File size exceeds the maximum allowed size of ${maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`,
400
);
return;
}
// Validate file type against database configuration
const allowedFileTypesStr = await getConfigValue('ALLOWED_FILE_TYPES', 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif');
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
const fileExtension = path.extname(file.originalname).replace('.', '').toLowerCase();
if (!allowedFileTypes.includes(fileExtension)) {
ResponseHandler.error(
res,
`File type "${fileExtension}" is not allowed. Allowed types: ${allowedFileTypes.join(', ')}`,
400
);
return;
}
const checksum = crypto.createHash('sha256').update(file.buffer || '').digest('hex');
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
const category = (req.body?.category as string) || 'OTHER';

View File

@ -0,0 +1,92 @@
import { QueryInterface, QueryTypes } from 'sequelize';
/**
* Migration to add AI model configuration entries
* Adds CLAUDE_MODEL, OPENAI_MODEL, and GEMINI_MODEL to admin_configurations
*
* This migration is idempotent - it will only insert if the configs don't exist
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Insert AI model configurations if they don't exist
await queryInterface.sequelize.query(`
INSERT INTO admin_configurations (
config_id, config_key, config_category, config_value, value_type,
display_name, description, default_value, is_editable, is_sensitive,
validation_rules, ui_component, options, sort_order, requires_restart,
last_modified_by, last_modified_at, created_at, updated_at
) VALUES
(
gen_random_uuid(),
'CLAUDE_MODEL',
'AI_CONFIGURATION',
'claude-sonnet-4-20250514',
'STRING',
'Claude Model',
'Claude (Anthropic) model to use for AI generation',
'claude-sonnet-4-20250514',
true,
false,
'{}'::jsonb,
'input',
NULL,
27,
false,
NULL,
NULL,
NOW(),
NOW()
),
(
gen_random_uuid(),
'OPENAI_MODEL',
'AI_CONFIGURATION',
'gpt-4o',
'STRING',
'OpenAI Model',
'OpenAI model to use for AI generation',
'gpt-4o',
true,
false,
'{}'::jsonb,
'input',
NULL,
28,
false,
NULL,
NULL,
NOW(),
NOW()
),
(
gen_random_uuid(),
'GEMINI_MODEL',
'AI_CONFIGURATION',
'gemini-2.0-flash-lite',
'STRING',
'Gemini Model',
'Gemini (Google) model to use for AI generation',
'gemini-2.0-flash-lite',
true,
false,
'{}'::jsonb,
'input',
NULL,
29,
false,
NULL,
NULL,
NOW(),
NOW()
)
ON CONFLICT (config_key) DO NOTHING
`, { type: QueryTypes.INSERT });
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Remove the AI model configurations
await queryInterface.sequelize.query(`
DELETE FROM admin_configurations
WHERE config_key IN ('CLAUDE_MODEL', 'OPENAI_MODEL', 'GEMINI_MODEL')
`, { type: QueryTypes.DELETE });
}

View File

@ -0,0 +1,94 @@
import { QueryInterface, QueryTypes } from 'sequelize';
/**
* Migration to add AI model configuration entries
* Adds CLAUDE_MODEL, OPENAI_MODEL, and GEMINI_MODEL to admin_configurations
*
* This migration is idempotent - it will only insert if the configs don't exist.
* For existing databases, this ensures the new model configuration fields are available.
* For fresh databases, the seed scripts will handle the initial population.
*/
export async function up(queryInterface: QueryInterface): Promise<void> {
// Insert AI model configurations if they don't exist
await queryInterface.sequelize.query(`
INSERT INTO admin_configurations (
config_id, config_key, config_category, config_value, value_type,
display_name, description, default_value, is_editable, is_sensitive,
validation_rules, ui_component, options, sort_order, requires_restart,
last_modified_by, last_modified_at, created_at, updated_at
) VALUES
(
gen_random_uuid(),
'CLAUDE_MODEL',
'AI_CONFIGURATION',
'claude-sonnet-4-20250514',
'STRING',
'Claude Model',
'Claude (Anthropic) model to use for AI generation',
'claude-sonnet-4-20250514',
true,
false,
'{}'::jsonb,
'input',
NULL,
27,
false,
NULL,
NULL,
NOW(),
NOW()
),
(
gen_random_uuid(),
'OPENAI_MODEL',
'AI_CONFIGURATION',
'gpt-4o',
'STRING',
'OpenAI Model',
'OpenAI model to use for AI generation',
'gpt-4o',
true,
false,
'{}'::jsonb,
'input',
NULL,
28,
false,
NULL,
NULL,
NOW(),
NOW()
),
(
gen_random_uuid(),
'GEMINI_MODEL',
'AI_CONFIGURATION',
'gemini-2.0-flash-lite',
'STRING',
'Gemini Model',
'Gemini (Google) model to use for AI generation',
'gemini-2.0-flash-lite',
true,
false,
'{}'::jsonb,
'input',
NULL,
29,
false,
NULL,
NULL,
NOW(),
NOW()
)
ON CONFLICT (config_key) DO NOTHING
`, { type: QueryTypes.INSERT });
}
export async function down(queryInterface: QueryInterface): Promise<void> {
// Remove the AI model configurations
await queryInterface.sequelize.query(`
DELETE FROM admin_configurations
WHERE config_key IN ('CLAUDE_MODEL', 'OPENAI_MODEL', 'GEMINI_MODEL')
`, { type: QueryTypes.DELETE });
}

View File

@ -21,7 +21,7 @@ const storage = multer.diskStorage({
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB - actual limit enforced by controller using database config
});
const router = Router();

View File

@ -2,6 +2,7 @@ import { Router } from 'express';
import { UserController } from '../controllers/user.controller';
import { authenticateToken } from '../middlewares/auth.middleware';
import { asyncHandler } from '../middlewares/errorHandler.middleware';
import { getPublicConfigurations } from '../controllers/admin.controller';
const router = Router();
const userController = new UserController();
@ -9,6 +10,9 @@ const userController = new UserController();
// GET /api/v1/users/search?q=<email or name>
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
// GET /api/v1/users/configurations - Get public configurations (document policy, workflow sharing, TAT settings)
router.get('/configurations', authenticateToken, asyncHandler(getPublicConfigurations));
// POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists)
router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController)));

View File

@ -6,12 +6,13 @@
* 1. Checks if database exists
* 2. Creates database if missing
* 3. Installs required extensions
* 4. Runs all pending migrations (18 total)
* 4. Runs all pending migrations (checks migrations table to avoid re-running)
* 5. Configs are auto-seeded by configSeed.service.ts on server start (30 configs)
*/
import { Client } from 'pg';
import { sequelize } from '../config/database';
import { QueryTypes } from 'sequelize';
import { exec } from 'child_process';
import { promisify } from 'util';
import dotenv from 'dotenv';
@ -85,17 +86,111 @@ async function checkAndCreateDatabase(): Promise<boolean> {
async function runMigrations(): Promise<void> {
try {
console.log('🔄 Running migrations...');
console.log('🔄 Checking and running pending migrations...');
// Run migrations using npm script
const { stdout, stderr } = await execAsync('npm run migrate', {
cwd: path.resolve(__dirname, '../..'),
});
// Import all migrations using require for CommonJS compatibility
// Some migrations use module.exports, others use export
const m0 = require('../migrations/2025103000-create-users');
const m1 = require('../migrations/2025103001-create-workflow-requests');
const m2 = require('../migrations/2025103002-create-approval-levels');
const m3 = require('../migrations/2025103003-create-participants');
const m4 = require('../migrations/2025103004-create-documents');
const m5 = require('../migrations/20251031_01_create_subscriptions');
const m6 = require('../migrations/20251031_02_create_activities');
const m7 = require('../migrations/20251031_03_create_work_notes');
const m8 = require('../migrations/20251031_04_create_work_note_attachments');
const m9 = require('../migrations/20251104-add-tat-alert-fields');
const m10 = require('../migrations/20251104-create-tat-alerts');
const m11 = require('../migrations/20251104-create-kpi-views');
const m12 = require('../migrations/20251104-create-holidays');
const m13 = require('../migrations/20251104-create-admin-config');
const m14 = require('../migrations/20251105-add-skip-fields-to-approval-levels');
const m15 = require('../migrations/2025110501-alter-tat-days-to-generated');
const m16 = require('../migrations/20251111-create-notifications');
const m17 = require('../migrations/20251111-create-conclusion-remarks');
const m18 = require('../migrations/20251118-add-breach-reason-to-approval-levels');
const m19 = require('../migrations/20251121-add-ai-model-configs');
if (stdout) console.log(stdout);
if (stderr && !stderr.includes('npm WARN')) console.error(stderr);
const migrations = [
{ name: '2025103000-create-users', module: m0 },
{ name: '2025103001-create-workflow-requests', module: m1 },
{ name: '2025103002-create-approval-levels', module: m2 },
{ name: '2025103003-create-participants', module: m3 },
{ name: '2025103004-create-documents', module: m4 },
{ name: '20251031_01_create_subscriptions', module: m5 },
{ name: '20251031_02_create_activities', module: m6 },
{ name: '20251031_03_create_work_notes', module: m7 },
{ name: '20251031_04_create_work_note_attachments', module: m8 },
{ name: '20251104-add-tat-alert-fields', module: m9 },
{ name: '20251104-create-tat-alerts', module: m10 },
{ name: '20251104-create-kpi-views', module: m11 },
{ name: '20251104-create-holidays', module: m12 },
{ name: '20251104-create-admin-config', module: m13 },
{ name: '20251105-add-skip-fields-to-approval-levels', module: m14 },
{ name: '2025110501-alter-tat-days-to-generated', module: m15 },
{ name: '20251111-create-notifications', module: m16 },
{ name: '20251111-create-conclusion-remarks', module: m17 },
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
{ name: '20251121-add-ai-model-configs', module: m19 },
];
console.log('✅ Migrations completed successfully!');
const queryInterface = sequelize.getQueryInterface();
// Ensure migrations tracking table exists
const tables = await queryInterface.showAllTables();
if (!tables.includes('migrations')) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
}
// Get already executed migrations
const executedResults = await sequelize.query<{ name: string }>(
'SELECT name FROM migrations ORDER BY id',
{ type: QueryTypes.SELECT }
);
const executedMigrations = executedResults.map(r => r.name);
// Find pending migrations
const pendingMigrations = migrations.filter(
m => !executedMigrations.includes(m.name)
);
if (pendingMigrations.length === 0) {
console.log('✅ Migrations up-to-date');
return;
}
console.log(`🔄 Running ${pendingMigrations.length} pending migration(s)...`);
// Run each pending migration
for (const migration of pendingMigrations) {
try {
console.log(`${migration.name}`);
// Call the up function - works for both module.exports and export styles
await migration.module.up(queryInterface);
// Mark as executed
await sequelize.query(
'INSERT INTO migrations (name) VALUES (:name) ON CONFLICT (name) DO NOTHING',
{
replacements: { name: migration.name },
type: QueryTypes.INSERT
}
);
console.log(`${migration.name}`);
} catch (error: any) {
console.error(`${migration.name} failed: ${error.message}`);
throw error;
}
}
console.log(`✅ Applied ${pendingMigrations.length} migration(s)`);
} catch (error: any) {
console.error('❌ Migration failed:', error.message);
throw error;

View File

@ -19,6 +19,7 @@ import * as m15 from '../migrations/2025110501-alter-tat-days-to-generated';
import * as m16 from '../migrations/20251111-create-notifications';
import * as m17 from '../migrations/20251111-create-conclusion-remarks';
import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-levels';
import * as m19 from '../migrations/20251121-add-ai-model-configs';
interface Migration {
name: string;
@ -52,6 +53,7 @@ const migrations: Migration[] = [
{ name: '20251111-create-notifications', module: m16 },
{ name: '20251111-create-conclusion-remarks', module: m17 },
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
{ name: '20251121-add-ai-model-configs', module: m19 },
];
/**

View File

@ -459,6 +459,60 @@ async function seedAdminConfigurations() {
NOW(),
NOW()
),
(
gen_random_uuid(),
'CLAUDE_MODEL',
'AI_CONFIGURATION',
'claude-sonnet-4-20250514',
'STRING',
'Claude Model',
'Claude (Anthropic) model to use for AI generation',
'claude-sonnet-4-20250514',
true,
false,
'{}'::jsonb,
'input',
105,
false,
NOW(),
NOW()
),
(
gen_random_uuid(),
'OPENAI_MODEL',
'AI_CONFIGURATION',
'gpt-4o',
'STRING',
'OpenAI Model',
'OpenAI model to use for AI generation',
'gpt-4o',
true,
false,
'{}'::jsonb,
'input',
106,
false,
NOW(),
NOW()
),
(
gen_random_uuid(),
'GEMINI_MODEL',
'AI_CONFIGURATION',
'gemini-2.0-flash-lite',
'STRING',
'Gemini Model',
'Gemini (Google) model to use for AI generation',
'gemini-2.0-flash-lite',
true,
false,
'{}'::jsonb,
'input',
107,
false,
NOW(),
NOW()
),
(
gen_random_uuid(),
'AI_REMARK_GENERATION_ENABLED',
@ -472,7 +526,7 @@ async function seedAdminConfigurations() {
false,
'{"type": "boolean"}'::jsonb,
'toggle',
105,
108,
false,
NOW(),
NOW()
@ -490,7 +544,7 @@ async function seedAdminConfigurations() {
false,
'{"type": "number", "min": 500, "max": 5000}'::jsonb,
'number',
106,
109,
false,
NOW(),
NOW()

View File

@ -13,12 +13,12 @@ class ClaudeProvider implements AIProvider {
private client: any = null;
private model: string;
constructor(apiKey?: string) {
// Allow model override via environment variable
constructor(apiKey?: string, model?: string) {
// Allow model override via parameter, environment variable, or default
// Current models (November 2025):
// - claude-sonnet-4-20250514 (default - latest Claude Sonnet 4)
// - Use env variable CLAUDE_MODEL to override if needed
this.model = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
// Priority: 1. Provided model parameter, 2. Environment variable, 3. Default
this.model = model || process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
try {
// Priority: 1. Provided key, 2. Environment variable
@ -70,9 +70,15 @@ class ClaudeProvider implements AIProvider {
// OpenAI Provider
class OpenAIProvider implements AIProvider {
private client: any = null;
private model: string = 'gpt-4o';
private model: string;
constructor(apiKey?: string) {
constructor(apiKey?: string, model?: string) {
// Allow model override via parameter, environment variable, or default
// Current models (November 2025):
// - gpt-4o (default - latest GPT-4 Optimized)
// Priority: 1. Provided model parameter, 2. Environment variable, 3. Default
this.model = model || process.env.OPENAI_MODEL || 'gpt-4o';
try {
// Priority: 1. Provided key, 2. Environment variable
const key = apiKey || process.env.OPENAI_API_KEY;
@ -83,7 +89,7 @@ class OpenAIProvider implements AIProvider {
const OpenAI = require('openai');
this.client = new OpenAI({ apiKey: key });
logger.info('[AI Service] ✅ OpenAI provider initialized');
logger.info(`[AI Service] ✅ OpenAI provider initialized with model: ${this.model}`);
} catch (error: any) {
// Handle missing package gracefully
if (error.code === 'MODULE_NOT_FOUND') {
@ -97,6 +103,8 @@ class OpenAIProvider implements AIProvider {
async generateText(prompt: string): Promise<string> {
if (!this.client) throw new Error('OpenAI client not initialized');
logger.info(`[AI Service] Generating with OpenAI model: ${this.model}`);
const response = await this.client.chat.completions.create({
model: this.model,
messages: [{ role: 'user', content: prompt }],
@ -119,9 +127,15 @@ class OpenAIProvider implements AIProvider {
// Gemini Provider (Google)
class GeminiProvider implements AIProvider {
private client: any = null;
private model: string = 'gemini-1.5-pro';
private model: string;
constructor(apiKey?: string) {
constructor(apiKey?: string, model?: string) {
// Allow model override via parameter, environment variable, or default
// Current models (November 2025):
// - gemini-2.0-flash-lite (default - latest Gemini Flash Lite)
// Priority: 1. Provided model parameter, 2. Environment variable, 3. Default
this.model = model || process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite';
try {
// Priority: 1. Provided key, 2. Environment variable
const key = apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY;
@ -132,7 +146,7 @@ class GeminiProvider implements AIProvider {
const { GoogleGenerativeAI } = require('@google/generative-ai');
this.client = new GoogleGenerativeAI(key);
logger.info('[AI Service] ✅ Gemini provider initialized');
logger.info(`[AI Service] ✅ Gemini provider initialized with model: ${this.model}`);
} catch (error: any) {
// Handle missing package gracefully
if (error.code === 'MODULE_NOT_FOUND') {
@ -146,6 +160,8 @@ class GeminiProvider implements AIProvider {
async generateText(prompt: string): Promise<string> {
if (!this.client) throw new Error('Gemini client not initialized');
logger.info(`[AI Service] Generating with Gemini model: ${this.model}`);
const model = this.client.getGenerativeModel({ model: this.model });
const result = await model.generateContent(prompt);
const response = await result.response;
@ -193,16 +209,16 @@ class AIService {
switch (preferredProvider) {
case 'openai':
case 'gpt':
initialized = this.tryProvider(new OpenAIProvider(config.openaiKey));
initialized = this.tryProvider(new OpenAIProvider(config.openaiKey, config.openaiModel));
break;
case 'gemini':
case 'google':
initialized = this.tryProvider(new GeminiProvider(config.geminiKey));
initialized = this.tryProvider(new GeminiProvider(config.geminiKey, config.geminiModel));
break;
case 'claude':
case 'anthropic':
default:
initialized = this.tryProvider(new ClaudeProvider(config.claudeKey));
initialized = this.tryProvider(new ClaudeProvider(config.claudeKey, config.claudeModel));
break;
}
@ -211,9 +227,9 @@ class AIService {
logger.warn('[AI Service] Preferred provider unavailable. Trying fallbacks...');
const fallbackProviders = [
new ClaudeProvider(config.claudeKey),
new OpenAIProvider(config.openaiKey),
new GeminiProvider(config.geminiKey)
new ClaudeProvider(config.claudeKey, config.claudeModel),
new OpenAIProvider(config.openaiKey, config.openaiModel),
new GeminiProvider(config.geminiKey, config.geminiModel)
];
for (const provider of fallbackProviders) {
@ -255,16 +271,16 @@ class AIService {
switch (preferredProvider) {
case 'openai':
case 'gpt':
this.tryProvider(new OpenAIProvider());
this.tryProvider(new OpenAIProvider(undefined, process.env.OPENAI_MODEL));
break;
case 'gemini':
case 'google':
this.tryProvider(new GeminiProvider());
this.tryProvider(new GeminiProvider(undefined, process.env.GEMINI_MODEL));
break;
case 'claude':
case 'anthropic':
default:
this.tryProvider(new ClaudeProvider());
this.tryProvider(new ClaudeProvider(undefined, process.env.CLAUDE_MODEL));
break;
}

View File

@ -127,14 +127,21 @@ export async function getAIProviderConfig(): Promise<{
claudeKey: string;
openaiKey: string;
geminiKey: string;
claudeModel: string;
openaiModel: string;
geminiModel: string;
enabled: boolean;
}> {
const provider = await getConfigValue('AI_PROVIDER', 'claude');
const claudeKey = await getConfigValue('CLAUDE_API_KEY', '');
const openaiKey = await getConfigValue('OPENAI_API_KEY', '');
const geminiKey = await getConfigValue('GEMINI_API_KEY', '');
// Get models from database config, fallback to env, then to defaults
const claudeModel = await getConfigValue('CLAUDE_MODEL', process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514');
const openaiModel = await getConfigValue('OPENAI_MODEL', process.env.OPENAI_MODEL || 'gpt-4o');
const geminiModel = await getConfigValue('GEMINI_MODEL', process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite');
const enabled = await getConfigBoolean('AI_ENABLED', true);
return { provider, claudeKey, openaiKey, geminiKey, enabled };
return { provider, claudeKey, openaiKey, geminiKey, claudeModel, openaiModel, geminiModel, enabled };
}

View File

@ -410,6 +410,69 @@ export async function seedDefaultConfigurations(): Promise<void> {
NOW(),
NOW()
),
(
gen_random_uuid(),
'CLAUDE_MODEL',
'AI_CONFIGURATION',
'claude-sonnet-4-20250514',
'STRING',
'Claude Model',
'Claude (Anthropic) model to use for AI generation',
'claude-sonnet-4-20250514',
true,
false,
'{}'::jsonb,
'input',
NULL,
27,
false,
NULL,
NULL,
NOW(),
NOW()
),
(
gen_random_uuid(),
'OPENAI_MODEL',
'AI_CONFIGURATION',
'gpt-4o',
'STRING',
'OpenAI Model',
'OpenAI model to use for AI generation',
'gpt-4o',
true,
false,
'{}'::jsonb,
'input',
NULL,
28,
false,
NULL,
NULL,
NOW(),
NOW()
),
(
gen_random_uuid(),
'GEMINI_MODEL',
'AI_CONFIGURATION',
'gemini-2.0-flash-lite',
'STRING',
'Gemini Model',
'Gemini (Google) model to use for AI generation',
'gemini-2.0-flash-lite',
true,
false,
'{}'::jsonb,
'input',
NULL,
29,
false,
NULL,
NULL,
NOW(),
NOW()
),
-- Notification Rules
(
gen_random_uuid(),

View File

@ -455,21 +455,38 @@ export class DashboardService {
type: QueryTypes.SELECT
});
// Get completed approvals in date range
// Get completed approvals
// completed_today should always be TODAY regardless of date range filter
// completed_this_week should be this week (Monday to Sunday)
// IMPORTANT: Only count approvals where the user is the approver (al.approver_id = userId)
const todayStart = dayjs().startOf('day').toDate();
const todayEnd = dayjs().endOf('day').toDate();
const weekStart = dayjs().startOf('week').toDate();
const weekEnd = dayjs().endOf('week').toDate();
const completedResult = await sequelize.query(`
SELECT
COUNT(*)::int AS completed_today,
COUNT(CASE WHEN al.action_date >= :weekStart THEN 1 END)::int AS completed_this_week
COUNT(CASE
WHEN al.action_date >= :todayStart
AND al.action_date <= :todayEnd
THEN 1
END)::int AS completed_today,
COUNT(CASE
WHEN al.action_date >= :weekStart
AND al.action_date <= :weekEnd
THEN 1
END)::int AS completed_this_week
FROM approval_levels al
WHERE al.approver_id = :userId
AND al.status IN ('APPROVED', 'REJECTED')
AND al.action_date BETWEEN :start AND :end
AND al.action_date IS NOT NULL
`, {
replacements: {
userId,
start: range.start,
end: range.end,
weekStart: dayjs().startOf('week').toDate()
todayStart,
todayEnd,
weekStart,
weekEnd
},
type: QueryTypes.SELECT
});
@ -1174,16 +1191,30 @@ export class DashboardService {
return null; // Skip this request - not actually breached
}
// Calculate breach time (hours since first breach)
// Calculate breach time (working hours since first breach)
let breachTime = 0;
if (req.first_breach_time) {
const breachDate = dayjs(req.first_breach_time);
const now = dayjs();
breachTime = now.diff(breachDate, 'hour', true);
// Use working hours calculation instead of calendar hours
// This ensures breach time is calculated in working hours, not calendar hours
try {
const { calculateElapsedWorkingHours } = await import('@utils/tatTimeUtils');
breachTime = await calculateElapsedWorkingHours(
req.first_breach_time,
new Date(),
priority
);
} catch (error) {
logger.error(`[Dashboard] Error calculating working hours for breach time:`, error);
// Fallback to calendar hours if working hours calculation fails
const breachDate = dayjs(req.first_breach_time);
const now = dayjs();
breachTime = now.diff(breachDate, 'hour', true);
}
} else if (req.breach_hours && req.breach_hours > 0) {
// breach_hours is already in working hours from tat_alerts table
breachTime = req.breach_hours;
} else if (currentLevelElapsedHours > currentLevelTatHours) {
// Calculate breach time from elapsed hours
// Calculate breach time from elapsed hours (already in working hours)
breachTime = currentLevelElapsedHours - currentLevelTatHours;
}
@ -2041,8 +2072,8 @@ export class DashboardService {
const user = await User.findByPk(userId);
const isAdmin = user?.hasManagementAccess() || false;
// Only admins can view other approvers' performance
if (!isAdmin) {
// Allow users to view their own performance, or admins to view any approver's performance
if (!isAdmin && approverId !== userId) {
return {
requests: [],
currentPage: page,

View File

@ -1649,12 +1649,13 @@ export class WorkflowService {
// Don't fail the submission if TAT scheduling fails
}
await notificationService.sendToUsers([(current as any).approverId], {
title: 'Request submitted',
body: `${(updated as any).title}`,
requestNumber: (updated as any).requestNumber,
url: `/request/${(updated as any).requestNumber}`
});
// NOTE: Notifications are already sent in createWorkflow() when the workflow is created
// We should NOT send "Request submitted" to the approver here - that's incorrect
// The approver should only receive "New Request Assigned" notification (sent in createWorkflow)
// The initiator receives "Request Submitted Successfully" notification (sent in createWorkflow)
//
// If this is a draft being submitted, notifications were already sent during creation,
// so we don't need to send them again here to avoid duplicates
}
return updated;
} catch (error) {