made some enhancement on dahboard with given changes
This commit is contained in:
parent
dcb53a89ed
commit
4da78f9b40
105
build/assets/charts-vendor-Cji9-Yri.js
Normal file
105
build/assets/charts-vendor-Cji9-Yri.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/charts-vendor-Cji9-Yri.js.map
Normal file
1
build/assets/charts-vendor-Cji9-Yri.js.map
Normal file
File diff suppressed because one or more lines are too long
2
build/assets/conclusionApi-LySNBiVn.js
Normal file
2
build/assets/conclusionApi-LySNBiVn.js
Normal 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
|
||||||
1
build/assets/conclusionApi-LySNBiVn.js.map
Normal file
1
build/assets/conclusionApi-LySNBiVn.js.map
Normal 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"}
|
||||||
1
build/assets/index-DI8aVCLa.css
Normal file
1
build/assets/index-DI8aVCLa.css
Normal file
File diff suppressed because one or more lines are too long
43
build/assets/index-DRwsycIY.js
Normal file
43
build/assets/index-DRwsycIY.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/index-DRwsycIY.js.map
Normal file
1
build/assets/index-DRwsycIY.js.map
Normal file
File diff suppressed because one or more lines are too long
73
build/assets/radix-vendor-CbkudDDo.js
Normal file
73
build/assets/radix-vendor-CbkudDDo.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/radix-vendor-CbkudDDo.js.map
Normal file
1
build/assets/radix-vendor-CbkudDDo.js.map
Normal file
File diff suppressed because one or more lines are too long
13
build/assets/router-vendor-1fSSvDCY.js
Normal file
13
build/assets/router-vendor-1fSSvDCY.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/router-vendor-1fSSvDCY.js.map
Normal file
1
build/assets/router-vendor-1fSSvDCY.js.map
Normal file
File diff suppressed because one or more lines are too long
BIN
build/assets/royal_enfield_logo-DcpkTndU.png
Normal file
BIN
build/assets/royal_enfield_logo-DcpkTndU.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
2
build/assets/socket-vendor-TjCxX7sJ.js
Normal file
2
build/assets/socket-vendor-TjCxX7sJ.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/socket-vendor-TjCxX7sJ.js.map
Normal file
1
build/assets/socket-vendor-TjCxX7sJ.js.map
Normal file
File diff suppressed because one or more lines are too long
533
build/assets/ui-vendor-i7LKlA3D.js
Normal file
533
build/assets/ui-vendor-i7LKlA3D.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/ui-vendor-i7LKlA3D.js.map
Normal file
1
build/assets/ui-vendor-i7LKlA3D.js.map
Normal file
File diff suppressed because one or more lines are too long
7
build/assets/utils-vendor-DHm03ykU.js
Normal file
7
build/assets/utils-vendor-DHm03ykU.js
Normal file
File diff suppressed because one or more lines are too long
1
build/assets/utils-vendor-DHm03ykU.js.map
Normal file
1
build/assets/utils-vendor-DHm03ykU.js.map
Normal file
File diff suppressed because one or more lines are too long
66
build/index.html
Normal file
66
build/index.html
Normal 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>
|
||||||
|
|
||||||
BIN
build/royal_enfield_logo.png
Normal file
BIN
build/royal_enfield_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
204
build/royal_enfield_logo.svg
Normal file
204
build/royal_enfield_logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 88 KiB |
26
build/service-worker.js
Normal file
26
build/service-worker.js
Normal 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
2
build/vite.svg
Normal 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 |
10
env.example
10
env.example
@ -38,8 +38,16 @@ SMTP_USER=notifications@royalenfield.com
|
|||||||
SMTP_PASSWORD=your_smtp_password
|
SMTP_PASSWORD=your_smtp_password
|
||||||
EMAIL_FROM=RE Workflow System <notifications@royalenfield.com>
|
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
|
CLAUDE_MODEL=claude-sonnet-4-20250514
|
||||||
|
OPENAI_MODEL=gpt-4o
|
||||||
|
GEMINI_MODEL=gemini-2.0-flash-lite
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
67
src/app.ts
67
src/app.ts
@ -66,7 +66,7 @@ app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|||||||
// Logging middleware
|
// Logging middleware
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint (before API routes)
|
||||||
app.get('/health', (_req: express.Request, res: express.Response) => {
|
app.get('/health', (_req: express.Request, res: express.Response) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
status: 'OK',
|
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);
|
app.use('/api/v1', routes);
|
||||||
|
|
||||||
// Serve uploaded files statically
|
// Serve uploaded files statically
|
||||||
ensureUploadDir();
|
ensureUploadDir();
|
||||||
app.use('/uploads', express.static(UPLOAD_DIR));
|
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)
|
// 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> => {
|
app.post('/api/v1/auth/sso-callback', async (req: express.Request, res: express.Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -183,13 +173,62 @@ app.get('/api/v1/users', async (_req: express.Request, res: express.Response): P
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handling middleware
|
// Serve React build static files (only in production or when build folder exists)
|
||||||
app.use((req: express.Request, res: express.Response) => {
|
// 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({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: `Route ${req.originalUrl} not found`,
|
message: `Route ${req.originalUrl} not found`,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
@ -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
|
* 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
|
// 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)) {
|
if (aiConfigKeys.includes(configKey)) {
|
||||||
try {
|
try {
|
||||||
const { aiService } = require('../services/ai.service');
|
const { aiService } = require('../services/ai.service');
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { ResponseHandler } from '@utils/responseHandler';
|
|||||||
import { activityService } from '@services/activity.service';
|
import { activityService } from '@services/activity.service';
|
||||||
import type { AuthenticatedRequest } from '../types/express';
|
import type { AuthenticatedRequest } from '../types/express';
|
||||||
import { getRequestMetadata } from '@utils/requestUtils';
|
import { getRequestMetadata } from '@utils/requestUtils';
|
||||||
|
import { getConfigNumber, getConfigValue } from '@services/configReader.service';
|
||||||
|
|
||||||
export class DocumentController {
|
export class DocumentController {
|
||||||
async upload(req: AuthenticatedRequest, res: Response): Promise<void> {
|
async upload(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
@ -29,6 +30,33 @@ export class DocumentController {
|
|||||||
return;
|
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 checksum = crypto.createHash('sha256').update(file.buffer || '').digest('hex');
|
||||||
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
|
const extension = path.extname(file.originalname).replace('.', '').toLowerCase();
|
||||||
const category = (req.body?.category as string) || 'OTHER';
|
const category = (req.body?.category as string) || 'OTHER';
|
||||||
|
|||||||
92
src/migrations/20250119-add-ai-model-configs.ts
Normal file
92
src/migrations/20250119-add-ai-model-configs.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
94
src/migrations/20251121-add-ai-model-configs.ts
Normal file
94
src/migrations/20251121-add-ai-model-configs.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ const storage = multer.diskStorage({
|
|||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB - actual limit enforced by controller using database config
|
||||||
});
|
});
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import { UserController } from '../controllers/user.controller';
|
import { UserController } from '../controllers/user.controller';
|
||||||
import { authenticateToken } from '../middlewares/auth.middleware';
|
import { authenticateToken } from '../middlewares/auth.middleware';
|
||||||
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
import { asyncHandler } from '../middlewares/errorHandler.middleware';
|
||||||
|
import { getPublicConfigurations } from '../controllers/admin.controller';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const userController = new UserController();
|
const userController = new UserController();
|
||||||
@ -9,6 +10,9 @@ const userController = new UserController();
|
|||||||
// GET /api/v1/users/search?q=<email or name>
|
// GET /api/v1/users/search?q=<email or name>
|
||||||
router.get('/search', authenticateToken, asyncHandler(userController.searchUsers.bind(userController)));
|
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)
|
// POST /api/v1/users/ensure - Ensure user exists in DB (create if not exists)
|
||||||
router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController)));
|
router.post('/ensure', authenticateToken, asyncHandler(userController.ensureUserExists.bind(userController)));
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,13 @@
|
|||||||
* 1. Checks if database exists
|
* 1. Checks if database exists
|
||||||
* 2. Creates database if missing
|
* 2. Creates database if missing
|
||||||
* 3. Installs required extensions
|
* 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)
|
* 5. Configs are auto-seeded by configSeed.service.ts on server start (30 configs)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Client } from 'pg';
|
import { Client } from 'pg';
|
||||||
import { sequelize } from '../config/database';
|
import { sequelize } from '../config/database';
|
||||||
|
import { QueryTypes } from 'sequelize';
|
||||||
import { exec } from 'child_process';
|
import { exec } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
@ -85,17 +86,111 @@ async function checkAndCreateDatabase(): Promise<boolean> {
|
|||||||
|
|
||||||
async function runMigrations(): Promise<void> {
|
async function runMigrations(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Running migrations...');
|
console.log('🔄 Checking and running pending migrations...');
|
||||||
|
|
||||||
// Run migrations using npm script
|
// Import all migrations using require for CommonJS compatibility
|
||||||
const { stdout, stderr } = await execAsync('npm run migrate', {
|
// Some migrations use module.exports, others use export
|
||||||
cwd: path.resolve(__dirname, '../..'),
|
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);
|
const migrations = [
|
||||||
if (stderr && !stderr.includes('npm WARN')) console.error(stderr);
|
{ 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) {
|
} catch (error: any) {
|
||||||
console.error('❌ Migration failed:', error.message);
|
console.error('❌ Migration failed:', error.message);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -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 m16 from '../migrations/20251111-create-notifications';
|
||||||
import * as m17 from '../migrations/20251111-create-conclusion-remarks';
|
import * as m17 from '../migrations/20251111-create-conclusion-remarks';
|
||||||
import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-levels';
|
import * as m18 from '../migrations/20251118-add-breach-reason-to-approval-levels';
|
||||||
|
import * as m19 from '../migrations/20251121-add-ai-model-configs';
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
name: string;
|
name: string;
|
||||||
@ -52,6 +53,7 @@ const migrations: Migration[] = [
|
|||||||
{ name: '20251111-create-notifications', module: m16 },
|
{ name: '20251111-create-notifications', module: m16 },
|
||||||
{ name: '20251111-create-conclusion-remarks', module: m17 },
|
{ name: '20251111-create-conclusion-remarks', module: m17 },
|
||||||
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
|
{ name: '20251118-add-breach-reason-to-approval-levels', module: m18 },
|
||||||
|
{ name: '20251121-add-ai-model-configs', module: m19 },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -459,6 +459,60 @@ async function seedAdminConfigurations() {
|
|||||||
NOW(),
|
NOW(),
|
||||||
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(),
|
gen_random_uuid(),
|
||||||
'AI_REMARK_GENERATION_ENABLED',
|
'AI_REMARK_GENERATION_ENABLED',
|
||||||
@ -472,7 +526,7 @@ async function seedAdminConfigurations() {
|
|||||||
false,
|
false,
|
||||||
'{"type": "boolean"}'::jsonb,
|
'{"type": "boolean"}'::jsonb,
|
||||||
'toggle',
|
'toggle',
|
||||||
105,
|
108,
|
||||||
false,
|
false,
|
||||||
NOW(),
|
NOW(),
|
||||||
NOW()
|
NOW()
|
||||||
@ -490,7 +544,7 @@ async function seedAdminConfigurations() {
|
|||||||
false,
|
false,
|
||||||
'{"type": "number", "min": 500, "max": 5000}'::jsonb,
|
'{"type": "number", "min": 500, "max": 5000}'::jsonb,
|
||||||
'number',
|
'number',
|
||||||
106,
|
109,
|
||||||
false,
|
false,
|
||||||
NOW(),
|
NOW(),
|
||||||
NOW()
|
NOW()
|
||||||
|
|||||||
@ -13,12 +13,12 @@ class ClaudeProvider implements AIProvider {
|
|||||||
private client: any = null;
|
private client: any = null;
|
||||||
private model: string;
|
private model: string;
|
||||||
|
|
||||||
constructor(apiKey?: string) {
|
constructor(apiKey?: string, model?: string) {
|
||||||
// Allow model override via environment variable
|
// Allow model override via parameter, environment variable, or default
|
||||||
// Current models (November 2025):
|
// Current models (November 2025):
|
||||||
// - claude-sonnet-4-20250514 (default - latest Claude Sonnet 4)
|
// - claude-sonnet-4-20250514 (default - latest Claude Sonnet 4)
|
||||||
// - Use env variable CLAUDE_MODEL to override if needed
|
// Priority: 1. Provided model parameter, 2. Environment variable, 3. Default
|
||||||
this.model = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
|
this.model = model || process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Priority: 1. Provided key, 2. Environment variable
|
// Priority: 1. Provided key, 2. Environment variable
|
||||||
@ -70,9 +70,15 @@ class ClaudeProvider implements AIProvider {
|
|||||||
// OpenAI Provider
|
// OpenAI Provider
|
||||||
class OpenAIProvider implements AIProvider {
|
class OpenAIProvider implements AIProvider {
|
||||||
private client: any = null;
|
private client: any = null;
|
||||||
private model: string = 'gpt-4o';
|
private model: 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';
|
||||||
|
|
||||||
constructor(apiKey?: string) {
|
|
||||||
try {
|
try {
|
||||||
// Priority: 1. Provided key, 2. Environment variable
|
// Priority: 1. Provided key, 2. Environment variable
|
||||||
const key = apiKey || process.env.OPENAI_API_KEY;
|
const key = apiKey || process.env.OPENAI_API_KEY;
|
||||||
@ -83,7 +89,7 @@ class OpenAIProvider implements AIProvider {
|
|||||||
|
|
||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
this.client = new OpenAI({ apiKey: key });
|
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) {
|
} catch (error: any) {
|
||||||
// Handle missing package gracefully
|
// Handle missing package gracefully
|
||||||
if (error.code === 'MODULE_NOT_FOUND') {
|
if (error.code === 'MODULE_NOT_FOUND') {
|
||||||
@ -97,6 +103,8 @@ class OpenAIProvider implements AIProvider {
|
|||||||
async generateText(prompt: string): Promise<string> {
|
async generateText(prompt: string): Promise<string> {
|
||||||
if (!this.client) throw new Error('OpenAI client not initialized');
|
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({
|
const response = await this.client.chat.completions.create({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
@ -119,9 +127,15 @@ class OpenAIProvider implements AIProvider {
|
|||||||
// Gemini Provider (Google)
|
// Gemini Provider (Google)
|
||||||
class GeminiProvider implements AIProvider {
|
class GeminiProvider implements AIProvider {
|
||||||
private client: any = null;
|
private client: any = null;
|
||||||
private model: string = 'gemini-1.5-pro';
|
private model: 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';
|
||||||
|
|
||||||
constructor(apiKey?: string) {
|
|
||||||
try {
|
try {
|
||||||
// Priority: 1. Provided key, 2. Environment variable
|
// Priority: 1. Provided key, 2. Environment variable
|
||||||
const key = apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_AI_API_KEY;
|
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');
|
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||||||
this.client = new GoogleGenerativeAI(key);
|
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) {
|
} catch (error: any) {
|
||||||
// Handle missing package gracefully
|
// Handle missing package gracefully
|
||||||
if (error.code === 'MODULE_NOT_FOUND') {
|
if (error.code === 'MODULE_NOT_FOUND') {
|
||||||
@ -146,6 +160,8 @@ class GeminiProvider implements AIProvider {
|
|||||||
async generateText(prompt: string): Promise<string> {
|
async generateText(prompt: string): Promise<string> {
|
||||||
if (!this.client) throw new Error('Gemini client not initialized');
|
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 model = this.client.getGenerativeModel({ model: this.model });
|
||||||
const result = await model.generateContent(prompt);
|
const result = await model.generateContent(prompt);
|
||||||
const response = await result.response;
|
const response = await result.response;
|
||||||
@ -193,16 +209,16 @@ class AIService {
|
|||||||
switch (preferredProvider) {
|
switch (preferredProvider) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
case 'gpt':
|
case 'gpt':
|
||||||
initialized = this.tryProvider(new OpenAIProvider(config.openaiKey));
|
initialized = this.tryProvider(new OpenAIProvider(config.openaiKey, config.openaiModel));
|
||||||
break;
|
break;
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
case 'google':
|
case 'google':
|
||||||
initialized = this.tryProvider(new GeminiProvider(config.geminiKey));
|
initialized = this.tryProvider(new GeminiProvider(config.geminiKey, config.geminiModel));
|
||||||
break;
|
break;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
default:
|
default:
|
||||||
initialized = this.tryProvider(new ClaudeProvider(config.claudeKey));
|
initialized = this.tryProvider(new ClaudeProvider(config.claudeKey, config.claudeModel));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,9 +227,9 @@ class AIService {
|
|||||||
logger.warn('[AI Service] Preferred provider unavailable. Trying fallbacks...');
|
logger.warn('[AI Service] Preferred provider unavailable. Trying fallbacks...');
|
||||||
|
|
||||||
const fallbackProviders = [
|
const fallbackProviders = [
|
||||||
new ClaudeProvider(config.claudeKey),
|
new ClaudeProvider(config.claudeKey, config.claudeModel),
|
||||||
new OpenAIProvider(config.openaiKey),
|
new OpenAIProvider(config.openaiKey, config.openaiModel),
|
||||||
new GeminiProvider(config.geminiKey)
|
new GeminiProvider(config.geminiKey, config.geminiModel)
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const provider of fallbackProviders) {
|
for (const provider of fallbackProviders) {
|
||||||
@ -255,16 +271,16 @@ class AIService {
|
|||||||
switch (preferredProvider) {
|
switch (preferredProvider) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
case 'gpt':
|
case 'gpt':
|
||||||
this.tryProvider(new OpenAIProvider());
|
this.tryProvider(new OpenAIProvider(undefined, process.env.OPENAI_MODEL));
|
||||||
break;
|
break;
|
||||||
case 'gemini':
|
case 'gemini':
|
||||||
case 'google':
|
case 'google':
|
||||||
this.tryProvider(new GeminiProvider());
|
this.tryProvider(new GeminiProvider(undefined, process.env.GEMINI_MODEL));
|
||||||
break;
|
break;
|
||||||
case 'claude':
|
case 'claude':
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
default:
|
default:
|
||||||
this.tryProvider(new ClaudeProvider());
|
this.tryProvider(new ClaudeProvider(undefined, process.env.CLAUDE_MODEL));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -127,14 +127,21 @@ export async function getAIProviderConfig(): Promise<{
|
|||||||
claudeKey: string;
|
claudeKey: string;
|
||||||
openaiKey: string;
|
openaiKey: string;
|
||||||
geminiKey: string;
|
geminiKey: string;
|
||||||
|
claudeModel: string;
|
||||||
|
openaiModel: string;
|
||||||
|
geminiModel: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}> {
|
}> {
|
||||||
const provider = await getConfigValue('AI_PROVIDER', 'claude');
|
const provider = await getConfigValue('AI_PROVIDER', 'claude');
|
||||||
const claudeKey = await getConfigValue('CLAUDE_API_KEY', '');
|
const claudeKey = await getConfigValue('CLAUDE_API_KEY', '');
|
||||||
const openaiKey = await getConfigValue('OPENAI_API_KEY', '');
|
const openaiKey = await getConfigValue('OPENAI_API_KEY', '');
|
||||||
const geminiKey = await getConfigValue('GEMINI_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);
|
const enabled = await getConfigBoolean('AI_ENABLED', true);
|
||||||
|
|
||||||
return { provider, claudeKey, openaiKey, geminiKey, enabled };
|
return { provider, claudeKey, openaiKey, geminiKey, claudeModel, openaiModel, geminiModel, enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -410,6 +410,69 @@ export async function seedDefaultConfigurations(): Promise<void> {
|
|||||||
NOW(),
|
NOW(),
|
||||||
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
|
-- Notification Rules
|
||||||
(
|
(
|
||||||
gen_random_uuid(),
|
gen_random_uuid(),
|
||||||
|
|||||||
@ -455,21 +455,38 @@ export class DashboardService {
|
|||||||
type: QueryTypes.SELECT
|
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(`
|
const completedResult = await sequelize.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*)::int AS completed_today,
|
COUNT(CASE
|
||||||
COUNT(CASE WHEN al.action_date >= :weekStart THEN 1 END)::int AS completed_this_week
|
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
|
FROM approval_levels al
|
||||||
WHERE al.approver_id = :userId
|
WHERE al.approver_id = :userId
|
||||||
AND al.status IN ('APPROVED', 'REJECTED')
|
AND al.status IN ('APPROVED', 'REJECTED')
|
||||||
AND al.action_date BETWEEN :start AND :end
|
AND al.action_date IS NOT NULL
|
||||||
`, {
|
`, {
|
||||||
replacements: {
|
replacements: {
|
||||||
userId,
|
userId,
|
||||||
start: range.start,
|
todayStart,
|
||||||
end: range.end,
|
todayEnd,
|
||||||
weekStart: dayjs().startOf('week').toDate()
|
weekStart,
|
||||||
|
weekEnd
|
||||||
},
|
},
|
||||||
type: QueryTypes.SELECT
|
type: QueryTypes.SELECT
|
||||||
});
|
});
|
||||||
@ -1174,16 +1191,30 @@ export class DashboardService {
|
|||||||
return null; // Skip this request - not actually breached
|
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;
|
let breachTime = 0;
|
||||||
if (req.first_breach_time) {
|
if (req.first_breach_time) {
|
||||||
|
// 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 breachDate = dayjs(req.first_breach_time);
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
breachTime = now.diff(breachDate, 'hour', true);
|
breachTime = now.diff(breachDate, 'hour', true);
|
||||||
|
}
|
||||||
} else if (req.breach_hours && req.breach_hours > 0) {
|
} else if (req.breach_hours && req.breach_hours > 0) {
|
||||||
|
// breach_hours is already in working hours from tat_alerts table
|
||||||
breachTime = req.breach_hours;
|
breachTime = req.breach_hours;
|
||||||
} else if (currentLevelElapsedHours > currentLevelTatHours) {
|
} else if (currentLevelElapsedHours > currentLevelTatHours) {
|
||||||
// Calculate breach time from elapsed hours
|
// Calculate breach time from elapsed hours (already in working hours)
|
||||||
breachTime = currentLevelElapsedHours - currentLevelTatHours;
|
breachTime = currentLevelElapsedHours - currentLevelTatHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2041,8 +2072,8 @@ export class DashboardService {
|
|||||||
const user = await User.findByPk(userId);
|
const user = await User.findByPk(userId);
|
||||||
const isAdmin = user?.hasManagementAccess() || false;
|
const isAdmin = user?.hasManagementAccess() || false;
|
||||||
|
|
||||||
// Only admins can view other approvers' performance
|
// Allow users to view their own performance, or admins to view any approver's performance
|
||||||
if (!isAdmin) {
|
if (!isAdmin && approverId !== userId) {
|
||||||
return {
|
return {
|
||||||
requests: [],
|
requests: [],
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
|
|||||||
@ -1649,12 +1649,13 @@ export class WorkflowService {
|
|||||||
// Don't fail the submission if TAT scheduling fails
|
// Don't fail the submission if TAT scheduling fails
|
||||||
}
|
}
|
||||||
|
|
||||||
await notificationService.sendToUsers([(current as any).approverId], {
|
// NOTE: Notifications are already sent in createWorkflow() when the workflow is created
|
||||||
title: 'Request submitted',
|
// We should NOT send "Request submitted" to the approver here - that's incorrect
|
||||||
body: `${(updated as any).title}`,
|
// The approver should only receive "New Request Assigned" notification (sent in createWorkflow)
|
||||||
requestNumber: (updated as any).requestNumber,
|
// The initiator receives "Request Submitted Successfully" notification (sent in createWorkflow)
|
||||||
url: `/request/${(updated as any).requestNumber}`
|
//
|
||||||
});
|
// 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;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user