Compare commits
2 Commits
e03049a861
...
bdfda74167
| Author | SHA1 | Date | |
|---|---|---|---|
| bdfda74167 | |||
| b925ee5217 |
@ -1,2 +1,2 @@
|
||||
import{a as s}from"./index-7JN9lLwu.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DbB0YGPu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B1UBYWWO.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||
//# sourceMappingURL=conclusionApi-CMghC3Jo.js.map
|
||||
import{a as s}from"./index-x1JLuWho.js";import"./radix-vendor-DIkYAdWy.js";import"./charts-vendor-Bme4E5cb.js";import"./utils-vendor-DNMmNUQL.js";import"./ui-vendor-DbB0YGPu.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-B1UBYWWO.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||
//# sourceMappingURL=conclusionApi-DX2Gwh6C.js.map
|
||||
@ -1 +1 @@
|
||||
{"version":3,"file":"conclusionApi-CMghC3Jo.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}
|
||||
{"version":3,"file":"conclusionApi-DX2Gwh6C.js","sources":["../../src/services/conclusionApi.ts"],"sourcesContent":["import apiClient from './authApi';\r\n\r\nexport interface ConclusionRemark {\r\n conclusionId: string;\r\n requestId: string;\r\n aiGeneratedRemark: string | null;\r\n aiModelUsed: string | null;\r\n aiConfidenceScore: number | null;\r\n finalRemark: string | null;\r\n editedBy: string | null;\r\n isEdited: boolean;\r\n editCount: number;\r\n approvalSummary: any;\r\n documentSummary: any;\r\n keyDiscussionPoints: string[];\r\n generatedAt: string | null;\r\n finalizedAt: string | null;\r\n createdAt: string;\r\n updatedAt: string;\r\n}\r\n\r\n/**\r\n * Generate AI-powered conclusion remark\r\n */\r\nexport async function generateConclusion(requestId: string): Promise<{\r\n conclusionId: string;\r\n aiGeneratedRemark: string;\r\n keyDiscussionPoints: string[];\r\n confidence: number;\r\n generatedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/generate`);\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Update conclusion remark (edit by initiator)\r\n */\r\nexport async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {\r\n const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Finalize conclusion and close request\r\n */\r\nexport async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{\r\n conclusionId: string;\r\n requestNumber: string;\r\n status: string;\r\n finalRemark: string;\r\n finalizedAt: string;\r\n}> {\r\n const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });\r\n return response.data.data;\r\n}\r\n\r\n/**\r\n * Get conclusion for a request\r\n * Returns null if conclusion doesn't exist (404) instead of throwing error\r\n */\r\nexport async function getConclusion(requestId: string): Promise<ConclusionRemark | null> {\r\n try {\r\n const response = await apiClient.get(`/conclusions/${requestId}`);\r\n return response.data.data;\r\n } catch (error: any) {\r\n // Handle 404 gracefully - conclusion doesn't exist yet, which is normal\r\n if (error.response?.status === 404) {\r\n return null;\r\n }\r\n // Re-throw other errors\r\n throw error;\r\n }\r\n}\r\n\r\n"],"names":["generateConclusion","requestId","apiClient","finalizeConclusion","finalRemark","getConclusion","error","_a"],"mappings":"6RAwBA,eAAsBA,EAAmBC,EAMtC,CAED,OADiB,MAAMC,EAAU,KAAK,gBAAgBD,CAAS,WAAW,GAC1D,KAAK,IACvB,CAaA,eAAsBE,EAAmBF,EAAmBG,EAMzD,CAED,OADiB,MAAMF,EAAU,KAAK,gBAAgBD,CAAS,YAAa,CAAE,YAAAG,EAAa,GAC3E,KAAK,IACvB,CAMA,eAAsBC,EAAcJ,EAAqD,OACvF,GAAI,CAEF,OADiB,MAAMC,EAAU,IAAI,gBAAgBD,CAAS,EAAE,GAChD,KAAK,IACvB,OAASK,EAAY,CAEnB,KAAIC,EAAAD,EAAM,WAAN,YAAAC,EAAgB,UAAW,IAC7B,OAAO,KAGT,MAAMD,CACR,CACF"}
|
||||
File diff suppressed because one or more lines are too long
1
build/assets/index-CNlPctO6.css
Normal file
1
build/assets/index-CNlPctO6.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -52,7 +52,7 @@
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-7JN9lLwu.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-x1JLuWho.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-Bme4E5cb.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-DIkYAdWy.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-DNMmNUQL.js">
|
||||
@ -60,7 +60,7 @@
|
||||
<link rel="modulepreload" crossorigin href="/assets/socket-vendor-TjCxX7sJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/redux-vendor-tbZCm13o.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/router-vendor-B1UBYWWO.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B-mLDzJe.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CNlPctO6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -3,6 +3,16 @@ import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
// 1. Debugging: Print what the app actually sees
|
||||
console.log('--- Database Config Debug ---');
|
||||
console.log(`DB_HOST: ${process.env.DB_HOST}`);
|
||||
console.log(`DB_SSL (Raw): '${process.env.DB_SSL}`); // Quotes help see trailing spaces
|
||||
|
||||
// 2. Fix: Trim whitespace to ensure "true " becomes "true"
|
||||
const isSSL = (process.env.DB_SSL || '').trim() === 'true';
|
||||
console.log(`SSL Enabled: ${isSSL}`);
|
||||
console.log('---------------------------');
|
||||
|
||||
const sequelize = new Sequelize({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||
@ -10,7 +20,7 @@ const sequelize = new Sequelize({
|
||||
username: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
dialect: 'postgres',
|
||||
logging: false, // Disable SQL query logging for cleaner console output
|
||||
logging: false,
|
||||
pool: {
|
||||
min: parseInt(process.env.DB_POOL_MIN || '2', 10),
|
||||
max: parseInt(process.env.DB_POOL_MAX || '10', 10),
|
||||
@ -18,11 +28,12 @@ const sequelize = new Sequelize({
|
||||
idle: 10000,
|
||||
},
|
||||
dialectOptions: {
|
||||
ssl: process.env.DB_SSL === 'true' ? {
|
||||
// 3. Use the robust boolean we calculated above
|
||||
ssl: isSSL ? {
|
||||
require: true,
|
||||
rejectUnauthorized: false,
|
||||
} : false,
|
||||
},
|
||||
});
|
||||
|
||||
export { sequelize };
|
||||
export { sequelize };
|
||||
@ -236,6 +236,7 @@ export class WorkflowController {
|
||||
priority: validated.priority as Priority,
|
||||
approvalLevels: enrichedApprovalLevels,
|
||||
participants: autoGeneratedParticipants,
|
||||
isDraft: parsed.isDraft === true, // Submit by default unless isDraft is explicitly true
|
||||
} as any;
|
||||
|
||||
const requestMeta = getRequestMetadata(req);
|
||||
@ -682,7 +683,10 @@ export class WorkflowController {
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
const validated = validateUpdateWorkflow(parsed);
|
||||
const updateData: UpdateWorkflowRequest = { ...validated } as any;
|
||||
const updateData: UpdateWorkflowRequest = {
|
||||
...validated,
|
||||
isDraft: parsed.isDraft !== undefined ? (parsed.isDraft === true) : undefined
|
||||
} as any;
|
||||
if (validated.priority) {
|
||||
updateData.priority = validated.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD;
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
// DB constants moved inside functions to ensure secrets are loaded first
|
||||
|
||||
const isSSL = (process.env.DB_SSL || '').trim() === 'true';
|
||||
async function checkAndCreateDatabase(): Promise<boolean> {
|
||||
const DB_HOST = process.env.DB_HOST || 'localhost';
|
||||
const DB_PORT = parseInt(process.env.DB_PORT || '5432');
|
||||
@ -36,6 +36,7 @@ async function checkAndCreateDatabase(): Promise<boolean> {
|
||||
user: DB_USER,
|
||||
password: DB_PASSWORD,
|
||||
database: 'postgres', // Connect to default postgres database
|
||||
ssl: isSSL ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
@ -64,6 +65,7 @@ async function checkAndCreateDatabase(): Promise<boolean> {
|
||||
user: DB_USER,
|
||||
password: DB_PASSWORD,
|
||||
database: DB_NAME,
|
||||
ssl: isSSL ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
||||
await newDbClient.connect();
|
||||
|
||||
@ -34,7 +34,7 @@ class GoogleSecretManagerService {
|
||||
constructor() {
|
||||
this.projectId = process.env.GCP_PROJECT_ID || '';
|
||||
this.secretPrefix = process.env.GCP_SECRET_PREFIX || '';
|
||||
|
||||
|
||||
// Load secret mapping file if provided
|
||||
const mapFile = process.env.GCP_SECRET_MAP_FILE;
|
||||
if (mapFile) {
|
||||
@ -66,7 +66,7 @@ class GoogleSecretManagerService {
|
||||
try {
|
||||
const keyFilePath = process.env.GCP_KEY_FILE || '';
|
||||
let originalCredentialsEnv: string | undefined;
|
||||
|
||||
|
||||
// If GCP_KEY_FILE is specified, set GOOGLE_APPLICATION_CREDENTIALS temporarily
|
||||
if (keyFilePath) {
|
||||
const resolvedKeyPath = path.isAbsolute(keyFilePath)
|
||||
@ -121,27 +121,27 @@ class GoogleSecretManagerService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullSecretName = this.secretPrefix
|
||||
? `${this.secretPrefix}-${secretName}`
|
||||
const fullSecretName = this.secretPrefix
|
||||
? `${this.secretPrefix}-${secretName}`
|
||||
: secretName;
|
||||
|
||||
|
||||
const name = `projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`;
|
||||
|
||||
try {
|
||||
const [version] = await this.client.accessSecretVersion({ name });
|
||||
|
||||
|
||||
if (version.payload?.data) {
|
||||
const secretValue = version.payload.data.toString();
|
||||
logger.debug(`[Secret Manager] ✅ Fetched secret: ${fullSecretName}`);
|
||||
return secretValue;
|
||||
}
|
||||
|
||||
|
||||
logger.debug(`[Secret Manager] Secret ${fullSecretName} exists but payload is empty`);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
const isOktaSecret = /OKTA_/i.test(secretName);
|
||||
const logLevel = isOktaSecret ? logger.info.bind(logger) : logger.debug.bind(logger);
|
||||
|
||||
|
||||
// Handle "not found" errors (code 5 = NOT_FOUND)
|
||||
if (error.code === 5 || error.code === 'NOT_FOUND' || error.message?.includes('not found')) {
|
||||
logLevel(`[Secret Manager] Secret not found: ${fullSecretName} (project: ${this.projectId})`);
|
||||
@ -150,7 +150,7 @@ class GoogleSecretManagerService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Handle permission errors (code 7 = PERMISSION_DENIED)
|
||||
if (error.code === 7 || error.code === 'PERMISSION_DENIED' || error.message?.includes('Permission denied')) {
|
||||
logger.warn(`[Secret Manager] ❌ Permission denied for secret '${fullSecretName}'`);
|
||||
@ -165,7 +165,7 @@ class GoogleSecretManagerService {
|
||||
logger.warn(`[Secret Manager] --project=${this.projectId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Log full error details for debugging (info level for OKTA secrets)
|
||||
const errorLogLevel = isOktaSecret ? logger.warn.bind(logger) : logger.warn.bind(logger);
|
||||
errorLogLevel(`[Secret Manager] Failed to fetch secret '${fullSecretName}' (project: ${this.projectId})`);
|
||||
@ -173,7 +173,7 @@ class GoogleSecretManagerService {
|
||||
if (error.details) {
|
||||
errorLogLevel(`[Secret Manager] Error details: ${JSON.stringify(error.details)}`);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -186,7 +186,7 @@ class GoogleSecretManagerService {
|
||||
if (this.secretMap[secretName]) {
|
||||
return this.secretMap[secretName];
|
||||
}
|
||||
|
||||
|
||||
// Default: convert secret name to uppercase and replace hyphens with underscores
|
||||
// Example: "db-password" -> "DB_PASSWORD", "JWT_SECRET" -> "JWT_SECRET"
|
||||
return secretName.toUpperCase().replace(/-/g, '_');
|
||||
@ -200,7 +200,7 @@ class GoogleSecretManagerService {
|
||||
*/
|
||||
async loadSecrets(secretNames?: string[]): Promise<void> {
|
||||
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||
|
||||
|
||||
if (!useSecretManager) {
|
||||
logger.debug('[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)');
|
||||
return;
|
||||
@ -238,19 +238,19 @@ class GoogleSecretManagerService {
|
||||
|
||||
// Load each secret
|
||||
for (const secretName of secretsToLoad) {
|
||||
const fullSecretName = this.secretPrefix
|
||||
? `${this.secretPrefix}-${secretName}`
|
||||
const fullSecretName = this.secretPrefix
|
||||
? `${this.secretPrefix}-${secretName}`
|
||||
: secretName;
|
||||
|
||||
|
||||
// Log OKTA and EMAIL secret attempts in detail
|
||||
const isOktaSecret = /^OKTA_/i.test(secretName);
|
||||
const isEmailSecret = /^EMAIL_|^SMTP_/i.test(secretName);
|
||||
if (isOktaSecret || isEmailSecret) {
|
||||
logger.info(`[Secret Manager] Attempting to load: ${secretName} (full name: ${fullSecretName})`);
|
||||
}
|
||||
|
||||
|
||||
const secretValue = await this.getSecret(secretName);
|
||||
|
||||
|
||||
if (secretValue !== null) {
|
||||
const envVarName = this.getEnvVarName(secretName);
|
||||
loadedSecrets[envVarName] = secretValue;
|
||||
@ -272,9 +272,9 @@ class GoogleSecretManagerService {
|
||||
for (const [envVar, value] of Object.entries(loadedSecrets)) {
|
||||
const existingValue = process.env[envVar];
|
||||
const isOverriding = existingValue !== undefined;
|
||||
|
||||
|
||||
process.env[envVar] = value;
|
||||
|
||||
|
||||
// Log override behavior for debugging
|
||||
if (isOverriding) {
|
||||
logger.debug(`[Secret Manager] 🔄 Overrode existing env var: ${envVar} (was: ${existingValue ? 'set' : 'undefined'}, now: from Secret Manager)`);
|
||||
@ -284,7 +284,7 @@ class GoogleSecretManagerService {
|
||||
}
|
||||
|
||||
logger.info(`[Secret Manager] ✅ Successfully loaded ${loadedCount}/${secretsToLoad.length} secrets`);
|
||||
|
||||
|
||||
if (loadedCount > 0) {
|
||||
const loadedVars = Object.keys(loadedSecrets);
|
||||
logger.info(`[Secret Manager] Loaded env vars: ${loadedVars.join(', ')}`);
|
||||
@ -300,7 +300,7 @@ class GoogleSecretManagerService {
|
||||
if (notFoundSecrets.length > 0) {
|
||||
const notFoundOkta = notFoundSecrets.filter(name => /OKTA_/i.test(name));
|
||||
const notFoundEmail = notFoundSecrets.filter(name => /EMAIL_|SMTP_/i.test(name));
|
||||
|
||||
|
||||
if (notFoundOkta.length > 0) {
|
||||
logger.warn(`[Secret Manager] ⚠️ OKTA secrets not found (${notFoundOkta.length}): ${notFoundOkta.join(', ')}`);
|
||||
logger.info(`[Secret Manager] 💡 To create OKTA secrets, use:`);
|
||||
@ -308,7 +308,7 @@ class GoogleSecretManagerService {
|
||||
logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (notFoundEmail.length > 0) {
|
||||
logger.warn(`[Secret Manager] ⚠️ EMAIL secrets not found (${notFoundEmail.length}): ${notFoundEmail.join(', ')}`);
|
||||
logger.info(`[Secret Manager] 💡 To create EMAIL secrets, use:`);
|
||||
@ -316,7 +316,7 @@ class GoogleSecretManagerService {
|
||||
logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const otherNotFound = notFoundSecrets.filter(name => !/OKTA_|EMAIL_|SMTP_/i.test(name));
|
||||
if (otherNotFound.length > 0) {
|
||||
logger.debug(`[Secret Manager] Other secrets not found (${otherNotFound.length}): ${otherNotFound.slice(0, 10).join(', ')}${otherNotFound.length > 10 ? '...' : ''}`);
|
||||
@ -337,18 +337,18 @@ class GoogleSecretManagerService {
|
||||
private getDefaultSecretNames(): string[] {
|
||||
return [
|
||||
// Database
|
||||
//'DB_PASSWORD',
|
||||
|
||||
'DB_PASSWORD',
|
||||
|
||||
// JWT & Session
|
||||
'JWT_SECRET',
|
||||
'REFRESH_TOKEN_SECRET',
|
||||
'SESSION_SECRET',
|
||||
|
||||
|
||||
// Okta/SSO
|
||||
//'OKTA_CLIENT_ID',
|
||||
//'OKTA_CLIENT_SECRET',
|
||||
//'OKTA_API_TOKEN',
|
||||
|
||||
|
||||
// Email
|
||||
'SMTP_HOST',
|
||||
'SMTP_PORT',
|
||||
@ -364,7 +364,7 @@ class GoogleSecretManagerService {
|
||||
*/
|
||||
async getSecretValue(secretName: string, envVarName?: string): Promise<string | null> {
|
||||
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||
|
||||
|
||||
if (!useSecretManager || !this.projectId) {
|
||||
return null;
|
||||
}
|
||||
@ -375,13 +375,13 @@ class GoogleSecretManagerService {
|
||||
}
|
||||
|
||||
const secretValue = await this.getSecret(secretName);
|
||||
|
||||
|
||||
if (secretValue !== null) {
|
||||
const envVar = envVarName || this.getEnvVarName(secretName);
|
||||
process.env[envVar] = secretValue;
|
||||
logger.debug(`[Secret Manager] ✅ Loaded secret ${secretName} -> ${envVar}`);
|
||||
}
|
||||
|
||||
|
||||
return secretValue;
|
||||
} catch (error: any) {
|
||||
logger.error(`[Secret Manager] Failed to get secret ${secretName}:`, error);
|
||||
@ -402,7 +402,7 @@ class GoogleSecretManagerService {
|
||||
*/
|
||||
async listSecrets(): Promise<string[]> {
|
||||
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
|
||||
|
||||
|
||||
if (!useSecretManager || !this.projectId) {
|
||||
logger.warn('[Secret Manager] Cannot list secrets: Secret Manager not enabled or project ID not set');
|
||||
return [];
|
||||
@ -419,7 +419,7 @@ class GoogleSecretManagerService {
|
||||
|
||||
const parent = `projects/${this.projectId}`;
|
||||
const [secrets] = await this.client.listSecrets({ parent });
|
||||
|
||||
|
||||
const secretNames = secrets.map(secret => {
|
||||
// Extract secret name from full path: projects/PROJECT/secrets/NAME -> NAME
|
||||
const nameParts = secret.name?.split('/') || [];
|
||||
|
||||
@ -2450,6 +2450,9 @@ export class WorkflowService {
|
||||
try {
|
||||
const requestNumber = await generateRequestNumber();
|
||||
const totalTatHours = workflowData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0);
|
||||
const isDraftRequested = workflowData.isDraft === true;
|
||||
const initialStatus = isDraftRequested ? WorkflowStatus.DRAFT : WorkflowStatus.PENDING;
|
||||
const now = new Date();
|
||||
|
||||
const workflow = await WorkflowRequest.create({
|
||||
requestNumber,
|
||||
@ -2461,9 +2464,10 @@ export class WorkflowService {
|
||||
currentLevel: 1,
|
||||
totalLevels: workflowData.approvalLevels.length,
|
||||
totalTatHours,
|
||||
status: WorkflowStatus.DRAFT,
|
||||
isDraft: true,
|
||||
isDeleted: false
|
||||
status: initialStatus,
|
||||
isDraft: isDraftRequested,
|
||||
isDeleted: false,
|
||||
submissionDate: isDraftRequested ? undefined : now
|
||||
});
|
||||
|
||||
// Create approval levels
|
||||
@ -2549,15 +2553,18 @@ export class WorkflowService {
|
||||
type: 'created',
|
||||
user: { userId: initiatorId, name: initiatorName },
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Initial request submitted',
|
||||
details: `Initial request submitted for ${workflowData.title} by ${initiatorName}`,
|
||||
action: isDraftRequested ? 'Draft request created' : 'Initial request submitted',
|
||||
details: isDraftRequested
|
||||
? `Draft request "${workflowData.title}" created by ${initiatorName}`
|
||||
: `Initial request submitted for ${workflowData.title} by ${initiatorName}`,
|
||||
ipAddress: requestMetadata?.ipAddress || undefined,
|
||||
userAgent: requestMetadata?.userAgent || undefined
|
||||
});
|
||||
|
||||
// NOTE: Notifications are NOT sent here because workflows are created as DRAFTS
|
||||
// Notifications will be sent in submitWorkflow() when the draft is actually submitted
|
||||
// This prevents approvers from being notified about draft requests
|
||||
// If not a draft, initiate the workflow (approvals, notifications, etc.)
|
||||
if (!isDraftRequested) {
|
||||
return await this.internalSubmitWorkflow(workflow, now);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
} catch (error) {
|
||||
@ -3112,6 +3119,9 @@ export class WorkflowService {
|
||||
// Only allow full updates (approval levels, participants) for DRAFT workflows
|
||||
const isDraft = (workflow as any).status === WorkflowStatus.DRAFT || (workflow as any).isDraft;
|
||||
|
||||
// Determine if this is a transition from draft to submitted
|
||||
const isTransitioningToSubmitted = updateData.isDraft === false && (workflow as any).isDraft;
|
||||
|
||||
// Update basic workflow fields
|
||||
const basicUpdate: any = {};
|
||||
if (updateData.title) basicUpdate.title = updateData.title;
|
||||
@ -3119,6 +3129,13 @@ export class WorkflowService {
|
||||
if (updateData.priority) basicUpdate.priority = updateData.priority;
|
||||
if (updateData.status) basicUpdate.status = updateData.status;
|
||||
if (updateData.conclusionRemark !== undefined) basicUpdate.conclusionRemark = updateData.conclusionRemark;
|
||||
if (updateData.isDraft !== undefined) basicUpdate.isDraft = updateData.isDraft;
|
||||
|
||||
// If transitioning, ensure status and submissionDate are set
|
||||
if (isTransitioningToSubmitted) {
|
||||
basicUpdate.status = WorkflowStatus.PENDING;
|
||||
basicUpdate.submissionDate = new Date();
|
||||
}
|
||||
|
||||
await workflow.update(basicUpdate);
|
||||
|
||||
@ -3267,8 +3284,13 @@ export class WorkflowService {
|
||||
logger.info(`Marked ${deleteResult[0]} documents as deleted in database (out of ${updateData.deleteDocumentIds.length} requested)`);
|
||||
}
|
||||
|
||||
// Reload the workflow instance to get latest data (without associations to avoid the error)
|
||||
// The associations issue occurs when trying to include them, so we skip that
|
||||
// If transitioning, call the internal submission logic (notifications, TAT, etc.)
|
||||
if (isTransitioningToSubmitted) {
|
||||
logger.info(`[Workflow] Transitioning draft ${actualRequestId} to submitted state`);
|
||||
return await this.internalSubmitWorkflow(workflow, (workflow as any).submissionDate);
|
||||
}
|
||||
|
||||
// Reload the workflow instance to get latest data
|
||||
const refreshed = await WorkflowRequest.findByPk(actualRequestId);
|
||||
return refreshed;
|
||||
} catch (error) {
|
||||
@ -3290,160 +3312,169 @@ export class WorkflowService {
|
||||
const workflow = await this.findWorkflowByIdentifier(requestId);
|
||||
if (!workflow) return null;
|
||||
|
||||
// Get the actual requestId (UUID) - handle both UUID and requestNumber cases
|
||||
const actualRequestId = (workflow as any).getDataValue
|
||||
? (workflow as any).getDataValue('requestId')
|
||||
: (workflow as any).requestId;
|
||||
|
||||
const now = new Date();
|
||||
const updated = await workflow.update({
|
||||
status: WorkflowStatus.PENDING,
|
||||
isDraft: false,
|
||||
submissionDate: now
|
||||
});
|
||||
|
||||
// Get initiator details for activity logging
|
||||
const initiatorId = (updated as any).initiatorId;
|
||||
const initiator = initiatorId ? await User.findByPk(initiatorId) : null;
|
||||
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
|
||||
const workflowTitle = (updated as any).title || 'Request';
|
||||
const requestNumber = (updated as any).requestNumber;
|
||||
|
||||
// Check if this was a previously saved draft (has activity history before submission)
|
||||
// or a direct submission (createWorkflow + submitWorkflow in same flow)
|
||||
const { Activity } = require('@models/Activity');
|
||||
const existingActivities = await Activity.count({
|
||||
where: { requestId: actualRequestId }
|
||||
});
|
||||
|
||||
// Only log "Request submitted" if this is a draft being submitted (has prior activities)
|
||||
// For direct submissions, createWorkflow already logs "Initial request submitted"
|
||||
if (existingActivities > 1) {
|
||||
// This is a saved draft being submitted later
|
||||
activityService.log({
|
||||
requestId: actualRequestId,
|
||||
type: 'submitted',
|
||||
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Draft submitted',
|
||||
details: `Draft request "${workflowTitle}" submitted for approval by ${initiatorName}`
|
||||
});
|
||||
} else {
|
||||
// Direct submission - just update the status, createWorkflow already logged the activity
|
||||
activityService.log({
|
||||
requestId: actualRequestId,
|
||||
type: 'submitted',
|
||||
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Request submitted',
|
||||
details: `Request "${workflowTitle}" submitted for approval`
|
||||
});
|
||||
}
|
||||
|
||||
const current = await ApprovalLevel.findOne({
|
||||
where: { requestId: actualRequestId, levelNumber: (updated as any).currentLevel || 1 }
|
||||
});
|
||||
if (current) {
|
||||
// Set the first level's start time and schedule TAT jobs
|
||||
await current.update({
|
||||
levelStartTime: now,
|
||||
tatStartTime: now,
|
||||
status: ApprovalStatus.IN_PROGRESS
|
||||
});
|
||||
|
||||
// Log assignment activity for the first approver (similar to createWorkflow)
|
||||
activityService.log({
|
||||
requestId: actualRequestId,
|
||||
type: 'assignment',
|
||||
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Assigned to approver',
|
||||
details: `Request assigned to ${(current as any).approverName || (current as any).approverEmail || 'approver'} for review`
|
||||
});
|
||||
|
||||
// Schedule TAT notification jobs for the first level
|
||||
try {
|
||||
const workflowPriority = (updated as any).priority || 'STANDARD';
|
||||
await tatSchedulerService.scheduleTatJobs(
|
||||
actualRequestId,
|
||||
(current as any).levelId,
|
||||
(current as any).approverId,
|
||||
Number((current as any).tatHours),
|
||||
now,
|
||||
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
|
||||
);
|
||||
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${requestNumber} (Priority: ${workflowPriority})`);
|
||||
} catch (tatError) {
|
||||
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
|
||||
// Don't fail the submission if TAT scheduling fails
|
||||
}
|
||||
|
||||
// Send notifications when workflow is submitted (not when created as draft)
|
||||
// Send notification to INITIATOR confirming submission
|
||||
await notificationService.sendToUsers([initiatorId], {
|
||||
title: 'Request Submitted Successfully',
|
||||
body: `Your request "${workflowTitle}" has been submitted and is now with the first approver.`,
|
||||
requestNumber: requestNumber,
|
||||
requestId: actualRequestId,
|
||||
url: `/request/${requestNumber}`,
|
||||
type: 'request_submitted',
|
||||
priority: 'MEDIUM'
|
||||
});
|
||||
|
||||
// Send notification to FIRST APPROVER for assignment
|
||||
await notificationService.sendToUsers([(current as any).approverId], {
|
||||
title: 'New Request Assigned',
|
||||
body: `${workflowTitle}`,
|
||||
requestNumber: requestNumber,
|
||||
requestId: actualRequestId,
|
||||
url: `/request/${requestNumber}`,
|
||||
type: 'assignment',
|
||||
priority: 'HIGH',
|
||||
actionRequired: true
|
||||
});
|
||||
}
|
||||
|
||||
// Send notifications to SPECTATORS (in-app, email, and web push)
|
||||
// Moved outside the if(current) block to ensure spectators are always notified on submission
|
||||
try {
|
||||
logger.info(`[Workflow] Querying spectators for request ${requestNumber} (requestId: ${actualRequestId})`);
|
||||
const spectators = await Participant.findAll({
|
||||
where: {
|
||||
requestId: actualRequestId, // Use the actual UUID requestId
|
||||
participantType: ParticipantType.SPECTATOR,
|
||||
isActive: true,
|
||||
notificationEnabled: true
|
||||
},
|
||||
attributes: ['userId', 'userEmail', 'userName']
|
||||
});
|
||||
|
||||
logger.info(`[Workflow] Found ${spectators.length} active spectators for request ${requestNumber}`);
|
||||
|
||||
if (spectators.length > 0) {
|
||||
const spectatorUserIds = spectators.map((s: any) => s.userId);
|
||||
logger.info(`[Workflow] Sending notifications to ${spectatorUserIds.length} spectators: ${spectatorUserIds.join(', ')}`);
|
||||
|
||||
await notificationService.sendToUsers(spectatorUserIds, {
|
||||
title: 'Added to Request',
|
||||
body: `You have been added as a spectator to request ${requestNumber}: ${workflowTitle}`,
|
||||
requestNumber: requestNumber,
|
||||
requestId: actualRequestId,
|
||||
url: `/request/${requestNumber}`,
|
||||
type: 'spectator_added',
|
||||
priority: 'MEDIUM'
|
||||
});
|
||||
logger.info(`[Workflow] Successfully sent notifications to ${spectators.length} spectators for request ${requestNumber}`);
|
||||
} else {
|
||||
logger.info(`[Workflow] No active spectators found for request ${requestNumber} (requestId: ${actualRequestId})`);
|
||||
}
|
||||
} catch (spectatorError) {
|
||||
logger.error(`[Workflow] Failed to send spectator notifications for request ${requestNumber} (requestId: ${actualRequestId}):`, spectatorError);
|
||||
// Don't fail the submission if spectator notifications fail
|
||||
}
|
||||
return updated;
|
||||
return await this.internalSubmitWorkflow(workflow, now);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to submit workflow ${requestId}:`, error);
|
||||
throw new Error('Failed to submit workflow');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to handle workflow submission logic (status update, notifications, TAT scheduling)
|
||||
* Centralized here to support both direct creation-submission and draft-to-submission flows.
|
||||
*/
|
||||
private async internalSubmitWorkflow(workflow: WorkflowRequest, now: Date): Promise<WorkflowRequest> {
|
||||
// Get the actual requestId (UUID) - handle both UUID and requestNumber cases
|
||||
const actualRequestId = (workflow as any).getDataValue
|
||||
? (workflow as any).getDataValue('requestId')
|
||||
: (workflow as any).requestId;
|
||||
|
||||
const updated = await workflow.update({
|
||||
status: WorkflowStatus.PENDING,
|
||||
isDraft: false,
|
||||
submissionDate: now
|
||||
});
|
||||
|
||||
// Get initiator details for activity logging
|
||||
const initiatorId = (updated as any).initiatorId;
|
||||
const initiator = initiatorId ? await User.findByPk(initiatorId) : null;
|
||||
const initiatorName = (initiator as any)?.displayName || (initiator as any)?.email || 'User';
|
||||
const workflowTitle = (updated as any).title || 'Request';
|
||||
const requestNumber = (updated as any).requestNumber;
|
||||
|
||||
// Check if this was a previously saved draft (has activity history before submission)
|
||||
// or a direct submission (createWorkflow + submitWorkflow in same flow)
|
||||
const { Activity } = require('@models/Activity');
|
||||
const existingActivities = await Activity.count({
|
||||
where: { requestId: actualRequestId }
|
||||
});
|
||||
|
||||
// Only log "Request submitted" if this is a draft being submitted (has prior activities)
|
||||
// For direct submissions, createWorkflow already logs "Initial request submitted"
|
||||
if (existingActivities > 1) {
|
||||
// This is a saved draft being submitted later
|
||||
activityService.log({
|
||||
requestId: actualRequestId,
|
||||
type: 'submitted',
|
||||
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Draft submitted',
|
||||
details: `Draft request "${workflowTitle}" submitted for approval by ${initiatorName}`
|
||||
});
|
||||
} else {
|
||||
// Direct submission - just update the status, createWorkflow already logged the activity
|
||||
activityService.log({
|
||||
requestId: actualRequestId,
|
||||
type: 'submitted',
|
||||
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Request submitted',
|
||||
details: `Request "${workflowTitle}" submitted for approval`
|
||||
});
|
||||
}
|
||||
|
||||
const current = await ApprovalLevel.findOne({
|
||||
where: { requestId: actualRequestId, levelNumber: (updated as any).currentLevel || 1 }
|
||||
});
|
||||
if (current) {
|
||||
// Set the first level's start time and schedule TAT jobs
|
||||
await current.update({
|
||||
levelStartTime: now,
|
||||
tatStartTime: now,
|
||||
status: ApprovalStatus.IN_PROGRESS
|
||||
});
|
||||
|
||||
// Log assignment activity for the first approver (similar to createWorkflow)
|
||||
activityService.log({
|
||||
requestId: actualRequestId,
|
||||
type: 'assignment',
|
||||
user: initiatorId ? { userId: initiatorId, name: initiatorName } : undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
action: 'Assigned to approver',
|
||||
details: `Request assigned to ${(current as any).approverName || (current as any).approverEmail || 'approver'} for review`
|
||||
});
|
||||
|
||||
// Schedule TAT notification jobs for the first level
|
||||
try {
|
||||
const workflowPriority = (updated as any).priority || 'STANDARD';
|
||||
await tatSchedulerService.scheduleTatJobs(
|
||||
actualRequestId,
|
||||
(current as any).levelId,
|
||||
(current as any).approverId,
|
||||
Number((current as any).tatHours),
|
||||
now,
|
||||
workflowPriority // Pass workflow priority (EXPRESS = 24/7, STANDARD = working hours)
|
||||
);
|
||||
logger.info(`[Workflow] TAT jobs scheduled for first level of request ${requestNumber} (Priority: ${workflowPriority})`);
|
||||
} catch (tatError) {
|
||||
logger.error(`[Workflow] Failed to schedule TAT jobs:`, tatError);
|
||||
// Don't fail the submission if TAT scheduling fails
|
||||
}
|
||||
|
||||
// Send notifications when workflow is submitted (not when created as draft)
|
||||
// Send notification to INITIATOR confirming submission
|
||||
await notificationService.sendToUsers([initiatorId], {
|
||||
title: 'Request Submitted Successfully',
|
||||
body: `Your request "${workflowTitle}" has been submitted and is now with the first approver.`,
|
||||
requestNumber: requestNumber,
|
||||
requestId: actualRequestId,
|
||||
url: `/request/${requestNumber}`,
|
||||
type: 'request_submitted',
|
||||
priority: 'MEDIUM'
|
||||
});
|
||||
|
||||
// Send notification to FIRST APPROVER for assignment
|
||||
await notificationService.sendToUsers([(current as any).approverId], {
|
||||
title: 'New Request Assigned',
|
||||
body: `${workflowTitle}`,
|
||||
requestNumber: requestNumber,
|
||||
requestId: actualRequestId,
|
||||
url: `/request/${requestNumber}`,
|
||||
type: 'assignment',
|
||||
priority: 'HIGH',
|
||||
actionRequired: true
|
||||
});
|
||||
}
|
||||
|
||||
// Send notifications to SPECTATORS (in-app, email, and web push)
|
||||
// Moved outside the if(current) block to ensure spectators are always notified on submission
|
||||
try {
|
||||
logger.info(`[Workflow] Querying spectators for request ${requestNumber} (requestId: ${actualRequestId})`);
|
||||
const spectators = await Participant.findAll({
|
||||
where: {
|
||||
requestId: actualRequestId, // Use the actual UUID requestId
|
||||
participantType: ParticipantType.SPECTATOR,
|
||||
isActive: true,
|
||||
notificationEnabled: true
|
||||
},
|
||||
attributes: ['userId', 'userEmail', 'userName']
|
||||
});
|
||||
|
||||
logger.info(`[Workflow] Found ${spectators.length} active spectators for request ${requestNumber}`);
|
||||
|
||||
if (spectators.length > 0) {
|
||||
const spectatorUserIds = spectators.map((s: any) => s.userId);
|
||||
logger.info(`[Workflow] Sending notifications to ${spectatorUserIds.length} spectators: ${spectatorUserIds.join(', ')}`);
|
||||
|
||||
await notificationService.sendToUsers(spectatorUserIds, {
|
||||
title: 'Added to Request',
|
||||
body: `You have been added as a spectator to request ${requestNumber}: ${workflowTitle}`,
|
||||
requestNumber: requestNumber,
|
||||
requestId: actualRequestId,
|
||||
url: `/request/${requestNumber}`,
|
||||
type: 'spectator_added',
|
||||
priority: 'MEDIUM'
|
||||
});
|
||||
logger.info(`[Workflow] Successfully sent notifications to ${spectators.length} spectators for request ${requestNumber}`);
|
||||
} else {
|
||||
logger.info(`[Workflow] No active spectators found for request ${requestNumber} (requestId: ${actualRequestId})`);
|
||||
}
|
||||
} catch (spectatorError) {
|
||||
logger.error(`[Workflow] Failed to send spectator notifications for request ${requestNumber} (requestId: ${actualRequestId}):`, spectatorError);
|
||||
// Don't fail the submission if spectator notifications fail
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ export interface CreateWorkflowRequest {
|
||||
priority: Priority;
|
||||
approvalLevels: CreateApprovalLevel[];
|
||||
participants?: CreateParticipant[];
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowRequest {
|
||||
@ -42,6 +43,7 @@ export interface UpdateWorkflowRequest {
|
||||
participants?: CreateParticipant[];
|
||||
// Document updates (add new documents via multipart, delete via IDs)
|
||||
deleteDocumentIds?: string[];
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateApprovalLevel {
|
||||
|
||||
@ -44,6 +44,7 @@ export const createWorkflowSchema = z.object({
|
||||
priorityUi: z.string().optional(),
|
||||
templateId: z.string().optional(),
|
||||
ccList: z.array(z.any()).optional(),
|
||||
isDraft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateWorkflowSchema = z.object({
|
||||
@ -73,6 +74,7 @@ export const updateWorkflowSchema = z.object({
|
||||
notificationEnabled: z.boolean().optional(),
|
||||
})).optional(),
|
||||
deleteDocumentIds: z.array(z.string().uuid()).optional(),
|
||||
isDraft: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Helper to validate UUID or requestNumber format
|
||||
|
||||
Loading…
Reference in New Issue
Block a user