Compare commits

..

2 Commits

14 changed files with 274 additions and 222 deletions

View File

@ -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}; 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-CMghC3Jo.js.map //# sourceMappingURL=conclusionApi-DX2Gwh6C.js.map

View File

@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -3,6 +3,16 @@ import dotenv from 'dotenv';
dotenv.config(); 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({ const sequelize = new Sequelize({
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10), port: parseInt(process.env.DB_PORT || '5432', 10),
@ -10,7 +20,7 @@ const sequelize = new Sequelize({
username: process.env.DB_USER || 'postgres', username: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres', password: process.env.DB_PASSWORD || 'postgres',
dialect: 'postgres', dialect: 'postgres',
logging: false, // Disable SQL query logging for cleaner console output logging: false,
pool: { pool: {
min: parseInt(process.env.DB_POOL_MIN || '2', 10), min: parseInt(process.env.DB_POOL_MIN || '2', 10),
max: parseInt(process.env.DB_POOL_MAX || '10', 10), max: parseInt(process.env.DB_POOL_MAX || '10', 10),
@ -18,11 +28,12 @@ const sequelize = new Sequelize({
idle: 10000, idle: 10000,
}, },
dialectOptions: { dialectOptions: {
ssl: process.env.DB_SSL === 'true' ? { // 3. Use the robust boolean we calculated above
ssl: isSSL ? {
require: true, require: true,
rejectUnauthorized: false, rejectUnauthorized: false,
} : false, } : false,
}, },
}); });
export { sequelize }; export { sequelize };

View File

@ -236,6 +236,7 @@ export class WorkflowController {
priority: validated.priority as Priority, priority: validated.priority as Priority,
approvalLevels: enrichedApprovalLevels, approvalLevels: enrichedApprovalLevels,
participants: autoGeneratedParticipants, participants: autoGeneratedParticipants,
isDraft: parsed.isDraft === true, // Submit by default unless isDraft is explicitly true
} as any; } as any;
const requestMeta = getRequestMetadata(req); const requestMeta = getRequestMetadata(req);
@ -682,7 +683,10 @@ export class WorkflowController {
} }
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const validated = validateUpdateWorkflow(parsed); 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) { if (validated.priority) {
updateData.priority = validated.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD; updateData.priority = validated.priority === 'EXPRESS' ? Priority.EXPRESS : Priority.STANDARD;
} }

View File

@ -22,7 +22,7 @@ dotenv.config({ path: path.resolve(__dirname, '../../.env') });
const execAsync = promisify(exec); const execAsync = promisify(exec);
// DB constants moved inside functions to ensure secrets are loaded first // DB constants moved inside functions to ensure secrets are loaded first
const isSSL = (process.env.DB_SSL || '').trim() === 'true';
async function checkAndCreateDatabase(): Promise<boolean> { async function checkAndCreateDatabase(): Promise<boolean> {
const DB_HOST = process.env.DB_HOST || 'localhost'; const DB_HOST = process.env.DB_HOST || 'localhost';
const DB_PORT = parseInt(process.env.DB_PORT || '5432'); const DB_PORT = parseInt(process.env.DB_PORT || '5432');
@ -36,6 +36,7 @@ async function checkAndCreateDatabase(): Promise<boolean> {
user: DB_USER, user: DB_USER,
password: DB_PASSWORD, password: DB_PASSWORD,
database: 'postgres', // Connect to default postgres database database: 'postgres', // Connect to default postgres database
ssl: isSSL ? { rejectUnauthorized: false } : undefined,
}); });
try { try {
@ -64,6 +65,7 @@ async function checkAndCreateDatabase(): Promise<boolean> {
user: DB_USER, user: DB_USER,
password: DB_PASSWORD, password: DB_PASSWORD,
database: DB_NAME, database: DB_NAME,
ssl: isSSL ? { rejectUnauthorized: false } : undefined,
}); });
await newDbClient.connect(); await newDbClient.connect();

View File

@ -34,7 +34,7 @@ class GoogleSecretManagerService {
constructor() { constructor() {
this.projectId = process.env.GCP_PROJECT_ID || ''; this.projectId = process.env.GCP_PROJECT_ID || '';
this.secretPrefix = process.env.GCP_SECRET_PREFIX || ''; this.secretPrefix = process.env.GCP_SECRET_PREFIX || '';
// Load secret mapping file if provided // Load secret mapping file if provided
const mapFile = process.env.GCP_SECRET_MAP_FILE; const mapFile = process.env.GCP_SECRET_MAP_FILE;
if (mapFile) { if (mapFile) {
@ -66,7 +66,7 @@ class GoogleSecretManagerService {
try { try {
const keyFilePath = process.env.GCP_KEY_FILE || ''; const keyFilePath = process.env.GCP_KEY_FILE || '';
let originalCredentialsEnv: string | undefined; let originalCredentialsEnv: string | undefined;
// If GCP_KEY_FILE is specified, set GOOGLE_APPLICATION_CREDENTIALS temporarily // If GCP_KEY_FILE is specified, set GOOGLE_APPLICATION_CREDENTIALS temporarily
if (keyFilePath) { if (keyFilePath) {
const resolvedKeyPath = path.isAbsolute(keyFilePath) const resolvedKeyPath = path.isAbsolute(keyFilePath)
@ -121,27 +121,27 @@ class GoogleSecretManagerService {
return null; return null;
} }
const fullSecretName = this.secretPrefix const fullSecretName = this.secretPrefix
? `${this.secretPrefix}-${secretName}` ? `${this.secretPrefix}-${secretName}`
: secretName; : secretName;
const name = `projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`; const name = `projects/${this.projectId}/secrets/${fullSecretName}/versions/latest`;
try { try {
const [version] = await this.client.accessSecretVersion({ name }); const [version] = await this.client.accessSecretVersion({ name });
if (version.payload?.data) { if (version.payload?.data) {
const secretValue = version.payload.data.toString(); const secretValue = version.payload.data.toString();
logger.debug(`[Secret Manager] ✅ Fetched secret: ${fullSecretName}`); logger.debug(`[Secret Manager] ✅ Fetched secret: ${fullSecretName}`);
return secretValue; return secretValue;
} }
logger.debug(`[Secret Manager] Secret ${fullSecretName} exists but payload is empty`); logger.debug(`[Secret Manager] Secret ${fullSecretName} exists but payload is empty`);
return null; return null;
} catch (error: any) { } catch (error: any) {
const isOktaSecret = /OKTA_/i.test(secretName); const isOktaSecret = /OKTA_/i.test(secretName);
const logLevel = isOktaSecret ? logger.info.bind(logger) : logger.debug.bind(logger); const logLevel = isOktaSecret ? logger.info.bind(logger) : logger.debug.bind(logger);
// Handle "not found" errors (code 5 = NOT_FOUND) // Handle "not found" errors (code 5 = NOT_FOUND)
if (error.code === 5 || error.code === 'NOT_FOUND' || error.message?.includes('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})`); logLevel(`[Secret Manager] Secret not found: ${fullSecretName} (project: ${this.projectId})`);
@ -150,7 +150,7 @@ class GoogleSecretManagerService {
} }
return null; return null;
} }
// Handle permission errors (code 7 = PERMISSION_DENIED) // Handle permission errors (code 7 = PERMISSION_DENIED)
if (error.code === 7 || error.code === 'PERMISSION_DENIED' || error.message?.includes('Permission denied')) { if (error.code === 7 || error.code === 'PERMISSION_DENIED' || error.message?.includes('Permission denied')) {
logger.warn(`[Secret Manager] ❌ Permission denied for secret '${fullSecretName}'`); logger.warn(`[Secret Manager] ❌ Permission denied for secret '${fullSecretName}'`);
@ -165,7 +165,7 @@ class GoogleSecretManagerService {
logger.warn(`[Secret Manager] --project=${this.projectId}`); logger.warn(`[Secret Manager] --project=${this.projectId}`);
return null; return null;
} }
// Log full error details for debugging (info level for OKTA secrets) // Log full error details for debugging (info level for OKTA secrets)
const errorLogLevel = isOktaSecret ? logger.warn.bind(logger) : logger.warn.bind(logger); const errorLogLevel = isOktaSecret ? logger.warn.bind(logger) : logger.warn.bind(logger);
errorLogLevel(`[Secret Manager] Failed to fetch secret '${fullSecretName}' (project: ${this.projectId})`); errorLogLevel(`[Secret Manager] Failed to fetch secret '${fullSecretName}' (project: ${this.projectId})`);
@ -173,7 +173,7 @@ class GoogleSecretManagerService {
if (error.details) { if (error.details) {
errorLogLevel(`[Secret Manager] Error details: ${JSON.stringify(error.details)}`); errorLogLevel(`[Secret Manager] Error details: ${JSON.stringify(error.details)}`);
} }
return null; return null;
} }
} }
@ -186,7 +186,7 @@ class GoogleSecretManagerService {
if (this.secretMap[secretName]) { if (this.secretMap[secretName]) {
return this.secretMap[secretName]; return this.secretMap[secretName];
} }
// Default: convert secret name to uppercase and replace hyphens with underscores // Default: convert secret name to uppercase and replace hyphens with underscores
// Example: "db-password" -> "DB_PASSWORD", "JWT_SECRET" -> "JWT_SECRET" // Example: "db-password" -> "DB_PASSWORD", "JWT_SECRET" -> "JWT_SECRET"
return secretName.toUpperCase().replace(/-/g, '_'); return secretName.toUpperCase().replace(/-/g, '_');
@ -200,7 +200,7 @@ class GoogleSecretManagerService {
*/ */
async loadSecrets(secretNames?: string[]): Promise<void> { async loadSecrets(secretNames?: string[]): Promise<void> {
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true'; const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useSecretManager) { if (!useSecretManager) {
logger.debug('[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)'); logger.debug('[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true)');
return; return;
@ -238,19 +238,19 @@ class GoogleSecretManagerService {
// Load each secret // Load each secret
for (const secretName of secretsToLoad) { for (const secretName of secretsToLoad) {
const fullSecretName = this.secretPrefix const fullSecretName = this.secretPrefix
? `${this.secretPrefix}-${secretName}` ? `${this.secretPrefix}-${secretName}`
: secretName; : secretName;
// Log OKTA and EMAIL secret attempts in detail // Log OKTA and EMAIL secret attempts in detail
const isOktaSecret = /^OKTA_/i.test(secretName); const isOktaSecret = /^OKTA_/i.test(secretName);
const isEmailSecret = /^EMAIL_|^SMTP_/i.test(secretName); const isEmailSecret = /^EMAIL_|^SMTP_/i.test(secretName);
if (isOktaSecret || isEmailSecret) { if (isOktaSecret || isEmailSecret) {
logger.info(`[Secret Manager] Attempting to load: ${secretName} (full name: ${fullSecretName})`); logger.info(`[Secret Manager] Attempting to load: ${secretName} (full name: ${fullSecretName})`);
} }
const secretValue = await this.getSecret(secretName); const secretValue = await this.getSecret(secretName);
if (secretValue !== null) { if (secretValue !== null) {
const envVarName = this.getEnvVarName(secretName); const envVarName = this.getEnvVarName(secretName);
loadedSecrets[envVarName] = secretValue; loadedSecrets[envVarName] = secretValue;
@ -272,9 +272,9 @@ class GoogleSecretManagerService {
for (const [envVar, value] of Object.entries(loadedSecrets)) { for (const [envVar, value] of Object.entries(loadedSecrets)) {
const existingValue = process.env[envVar]; const existingValue = process.env[envVar];
const isOverriding = existingValue !== undefined; const isOverriding = existingValue !== undefined;
process.env[envVar] = value; process.env[envVar] = value;
// Log override behavior for debugging // Log override behavior for debugging
if (isOverriding) { if (isOverriding) {
logger.debug(`[Secret Manager] 🔄 Overrode existing env var: ${envVar} (was: ${existingValue ? 'set' : 'undefined'}, now: from Secret Manager)`); 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`); logger.info(`[Secret Manager] ✅ Successfully loaded ${loadedCount}/${secretsToLoad.length} secrets`);
if (loadedCount > 0) { if (loadedCount > 0) {
const loadedVars = Object.keys(loadedSecrets); const loadedVars = Object.keys(loadedSecrets);
logger.info(`[Secret Manager] Loaded env vars: ${loadedVars.join(', ')}`); logger.info(`[Secret Manager] Loaded env vars: ${loadedVars.join(', ')}`);
@ -300,7 +300,7 @@ class GoogleSecretManagerService {
if (notFoundSecrets.length > 0) { if (notFoundSecrets.length > 0) {
const notFoundOkta = notFoundSecrets.filter(name => /OKTA_/i.test(name)); const notFoundOkta = notFoundSecrets.filter(name => /OKTA_/i.test(name));
const notFoundEmail = notFoundSecrets.filter(name => /EMAIL_|SMTP_/i.test(name)); const notFoundEmail = notFoundSecrets.filter(name => /EMAIL_|SMTP_/i.test(name));
if (notFoundOkta.length > 0) { if (notFoundOkta.length > 0) {
logger.warn(`[Secret Manager] ⚠️ OKTA secrets not found (${notFoundOkta.length}): ${notFoundOkta.join(', ')}`); logger.warn(`[Secret Manager] ⚠️ OKTA secrets not found (${notFoundOkta.length}): ${notFoundOkta.join(', ')}`);
logger.info(`[Secret Manager] 💡 To create OKTA secrets, use:`); 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}`); logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
}); });
} }
if (notFoundEmail.length > 0) { if (notFoundEmail.length > 0) {
logger.warn(`[Secret Manager] ⚠️ EMAIL secrets not found (${notFoundEmail.length}): ${notFoundEmail.join(', ')}`); logger.warn(`[Secret Manager] ⚠️ EMAIL secrets not found (${notFoundEmail.length}): ${notFoundEmail.join(', ')}`);
logger.info(`[Secret Manager] 💡 To create EMAIL secrets, use:`); 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}`); logger.info(`[Secret Manager] gcloud secrets create ${secretName} --data-file=- --project=${this.projectId}`);
}); });
} }
const otherNotFound = notFoundSecrets.filter(name => !/OKTA_|EMAIL_|SMTP_/i.test(name)); const otherNotFound = notFoundSecrets.filter(name => !/OKTA_|EMAIL_|SMTP_/i.test(name));
if (otherNotFound.length > 0) { if (otherNotFound.length > 0) {
logger.debug(`[Secret Manager] Other secrets not found (${otherNotFound.length}): ${otherNotFound.slice(0, 10).join(', ')}${otherNotFound.length > 10 ? '...' : ''}`); 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[] { private getDefaultSecretNames(): string[] {
return [ return [
// Database // Database
//'DB_PASSWORD', 'DB_PASSWORD',
// JWT & Session // JWT & Session
'JWT_SECRET', 'JWT_SECRET',
'REFRESH_TOKEN_SECRET', 'REFRESH_TOKEN_SECRET',
'SESSION_SECRET', 'SESSION_SECRET',
// Okta/SSO // Okta/SSO
//'OKTA_CLIENT_ID', //'OKTA_CLIENT_ID',
//'OKTA_CLIENT_SECRET', //'OKTA_CLIENT_SECRET',
//'OKTA_API_TOKEN', //'OKTA_API_TOKEN',
// Email // Email
'SMTP_HOST', 'SMTP_HOST',
'SMTP_PORT', 'SMTP_PORT',
@ -364,7 +364,7 @@ class GoogleSecretManagerService {
*/ */
async getSecretValue(secretName: string, envVarName?: string): Promise<string | null> { async getSecretValue(secretName: string, envVarName?: string): Promise<string | null> {
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true'; const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useSecretManager || !this.projectId) { if (!useSecretManager || !this.projectId) {
return null; return null;
} }
@ -375,13 +375,13 @@ class GoogleSecretManagerService {
} }
const secretValue = await this.getSecret(secretName); const secretValue = await this.getSecret(secretName);
if (secretValue !== null) { if (secretValue !== null) {
const envVar = envVarName || this.getEnvVarName(secretName); const envVar = envVarName || this.getEnvVarName(secretName);
process.env[envVar] = secretValue; process.env[envVar] = secretValue;
logger.debug(`[Secret Manager] ✅ Loaded secret ${secretName} -> ${envVar}`); logger.debug(`[Secret Manager] ✅ Loaded secret ${secretName} -> ${envVar}`);
} }
return secretValue; return secretValue;
} catch (error: any) { } catch (error: any) {
logger.error(`[Secret Manager] Failed to get secret ${secretName}:`, error); logger.error(`[Secret Manager] Failed to get secret ${secretName}:`, error);
@ -402,7 +402,7 @@ class GoogleSecretManagerService {
*/ */
async listSecrets(): Promise<string[]> { async listSecrets(): Promise<string[]> {
const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true'; const useSecretManager = process.env.USE_GOOGLE_SECRET_MANAGER === 'true';
if (!useSecretManager || !this.projectId) { if (!useSecretManager || !this.projectId) {
logger.warn('[Secret Manager] Cannot list secrets: Secret Manager not enabled or project ID not set'); logger.warn('[Secret Manager] Cannot list secrets: Secret Manager not enabled or project ID not set');
return []; return [];
@ -419,7 +419,7 @@ class GoogleSecretManagerService {
const parent = `projects/${this.projectId}`; const parent = `projects/${this.projectId}`;
const [secrets] = await this.client.listSecrets({ parent }); const [secrets] = await this.client.listSecrets({ parent });
const secretNames = secrets.map(secret => { const secretNames = secrets.map(secret => {
// Extract secret name from full path: projects/PROJECT/secrets/NAME -> NAME // Extract secret name from full path: projects/PROJECT/secrets/NAME -> NAME
const nameParts = secret.name?.split('/') || []; const nameParts = secret.name?.split('/') || [];

View File

@ -2450,6 +2450,9 @@ export class WorkflowService {
try { try {
const requestNumber = await generateRequestNumber(); const requestNumber = await generateRequestNumber();
const totalTatHours = workflowData.approvalLevels.reduce((sum, level) => sum + level.tatHours, 0); 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({ const workflow = await WorkflowRequest.create({
requestNumber, requestNumber,
@ -2461,9 +2464,10 @@ export class WorkflowService {
currentLevel: 1, currentLevel: 1,
totalLevels: workflowData.approvalLevels.length, totalLevels: workflowData.approvalLevels.length,
totalTatHours, totalTatHours,
status: WorkflowStatus.DRAFT, status: initialStatus,
isDraft: true, isDraft: isDraftRequested,
isDeleted: false isDeleted: false,
submissionDate: isDraftRequested ? undefined : now
}); });
// Create approval levels // Create approval levels
@ -2549,15 +2553,18 @@ export class WorkflowService {
type: 'created', type: 'created',
user: { userId: initiatorId, name: initiatorName }, user: { userId: initiatorId, name: initiatorName },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
action: 'Initial request submitted', action: isDraftRequested ? 'Draft request created' : 'Initial request submitted',
details: `Initial request submitted for ${workflowData.title} by ${initiatorName}`, details: isDraftRequested
? `Draft request "${workflowData.title}" created by ${initiatorName}`
: `Initial request submitted for ${workflowData.title} by ${initiatorName}`,
ipAddress: requestMetadata?.ipAddress || undefined, ipAddress: requestMetadata?.ipAddress || undefined,
userAgent: requestMetadata?.userAgent || undefined userAgent: requestMetadata?.userAgent || undefined
}); });
// NOTE: Notifications are NOT sent here because workflows are created as DRAFTS // If not a draft, initiate the workflow (approvals, notifications, etc.)
// Notifications will be sent in submitWorkflow() when the draft is actually submitted if (!isDraftRequested) {
// This prevents approvers from being notified about draft requests return await this.internalSubmitWorkflow(workflow, now);
}
return workflow; return workflow;
} catch (error) { } catch (error) {
@ -3112,6 +3119,9 @@ export class WorkflowService {
// Only allow full updates (approval levels, participants) for DRAFT workflows // Only allow full updates (approval levels, participants) for DRAFT workflows
const isDraft = (workflow as any).status === WorkflowStatus.DRAFT || (workflow as any).isDraft; 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 // Update basic workflow fields
const basicUpdate: any = {}; const basicUpdate: any = {};
if (updateData.title) basicUpdate.title = updateData.title; if (updateData.title) basicUpdate.title = updateData.title;
@ -3119,6 +3129,13 @@ export class WorkflowService {
if (updateData.priority) basicUpdate.priority = updateData.priority; if (updateData.priority) basicUpdate.priority = updateData.priority;
if (updateData.status) basicUpdate.status = updateData.status; if (updateData.status) basicUpdate.status = updateData.status;
if (updateData.conclusionRemark !== undefined) basicUpdate.conclusionRemark = updateData.conclusionRemark; 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); 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)`); 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) // If transitioning, call the internal submission logic (notifications, TAT, etc.)
// The associations issue occurs when trying to include them, so we skip that 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); const refreshed = await WorkflowRequest.findByPk(actualRequestId);
return refreshed; return refreshed;
} catch (error) { } catch (error) {
@ -3290,160 +3312,169 @@ export class WorkflowService {
const workflow = await this.findWorkflowByIdentifier(requestId); const workflow = await this.findWorkflowByIdentifier(requestId);
if (!workflow) return null; 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 now = new Date();
const updated = await workflow.update({ return await this.internalSubmitWorkflow(workflow, now);
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;
} catch (error) { } catch (error) {
logger.error(`Failed to submit workflow ${requestId}:`, error); logger.error(`Failed to submit workflow ${requestId}:`, error);
throw new Error('Failed to submit workflow'); 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;
}
} }

View File

@ -29,6 +29,7 @@ export interface CreateWorkflowRequest {
priority: Priority; priority: Priority;
approvalLevels: CreateApprovalLevel[]; approvalLevels: CreateApprovalLevel[];
participants?: CreateParticipant[]; participants?: CreateParticipant[];
isDraft?: boolean;
} }
export interface UpdateWorkflowRequest { export interface UpdateWorkflowRequest {
@ -42,6 +43,7 @@ export interface UpdateWorkflowRequest {
participants?: CreateParticipant[]; participants?: CreateParticipant[];
// Document updates (add new documents via multipart, delete via IDs) // Document updates (add new documents via multipart, delete via IDs)
deleteDocumentIds?: string[]; deleteDocumentIds?: string[];
isDraft?: boolean;
} }
export interface CreateApprovalLevel { export interface CreateApprovalLevel {

View File

@ -44,6 +44,7 @@ export const createWorkflowSchema = z.object({
priorityUi: z.string().optional(), priorityUi: z.string().optional(),
templateId: z.string().optional(), templateId: z.string().optional(),
ccList: z.array(z.any()).optional(), ccList: z.array(z.any()).optional(),
isDraft: z.boolean().optional(),
}); });
export const updateWorkflowSchema = z.object({ export const updateWorkflowSchema = z.object({
@ -73,6 +74,7 @@ export const updateWorkflowSchema = z.object({
notificationEnabled: z.boolean().optional(), notificationEnabled: z.boolean().optional(),
})).optional(), })).optional(),
deleteDocumentIds: z.array(z.string().uuid()).optional(), deleteDocumentIds: z.array(z.string().uuid()).optional(),
isDraft: z.boolean().optional(),
}); });
// Helper to validate UUID or requestNumber format // Helper to validate UUID or requestNumber format