/** * Dealer termination E2E — follows the same stage order and APIs as the UI. * * Usage: * node trigger-termination.js * node trigger-termination.js --terminationId= --delayMs=800 * node trigger-termination.js --category=Unethical --skipClearances=true */ const args = Object.fromEntries( process.argv .slice(2) .map((arg) => arg.replace(/^--/, '').split('=')) .map(([k, v]) => [k, v ?? 'true']) ); const BASE_URL = args.baseUrl || process.env.BASE_URL || 'http://localhost:5000/api'; const PASSWORD = 'Admin@123'; const STEP_DELAY_MS = Number(args.delayMs || 800); const SHOULD_SKIP_CLEARANCES = String(args.skipClearances || 'false') === 'true'; const EMAILS = { DD_ADMIN: 'lince@royalenfield.com', ASM: 'abhishek@royalenfield.com', RBM: 'manish@royalenfield.com', DD_ZM: 'piyush@royalenfield.com', ZBH: 'manav@royalenfield.com', DD_LEAD: 'jaya@royalenfield.com', DD_HEAD: 'ganesh@royalenfield.com', LEGAL: 'legal@royalenfield.com', NBH: 'yashwin@royalenfield.com', CCO: 'cco@royalenfield.com', CEO: 'ceo@royalenfield.com' }; /** Canonical stage labels (match TERMINATION_STAGES + UI timeline). */ const STAGES = { RBM: 'RBM + DD-ZM Review', ZBH: 'ZBH Review', DD_LEAD: 'DD Lead Review', LEGAL: 'Legal Verification', DD_HEAD: 'DD Head Review', NBH: 'NBH Evaluation', SCN: 'Show Cause Notice (SCN)', SCN_EVAL: 'Evaluation of Dealer SCN Response', NBH_FINAL: 'NBH Final Approval', CCO: 'CCO Approval', CEO: 'CEO Final Approval', LEGAL_LETTER: 'Legal - Termination Letter', TERMINATED: 'Terminated' }; async function apiRequest(endpoint, method = 'GET', body = null, token = null, isFormData = false) { const headers = {}; if (token) headers['Authorization'] = `Bearer ${token}`; if (!isFormData) headers['Content-Type'] = 'application/json'; const config = { method, headers }; if (body) config.body = isFormData ? body : JSON.stringify(body); const response = await fetch(`${BASE_URL}${endpoint}`, config); const data = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`API Error ${method} ${endpoint}: ${JSON.stringify(data)}`); } return data; } async function login(email) { if (!login.cache) login.cache = {}; if (login.cache[email]) return login.cache[email]; const data = await apiRequest('/auth/login', 'POST', { email, password: PASSWORD }); login.cache[email] = data.token; return login.cache[email]; } const delay = (ms = STEP_DELAY_MS) => new Promise((res) => setTimeout(res, ms)); const log = (step, msg) => console.log(`[STEP ${step}] ${msg}`); async function fetchTermination(terminationId, token) { const data = await apiRequest(`/termination/${terminationId}`, 'GET', null, token); return data.termination; } async function waitUntilStage(terminationId, expectedStage, token, maxAttempts = 50) { for (let attempt = 0; attempt < maxAttempts; attempt++) { const t = await fetchTermination(terminationId, token); if (t.currentStage === expectedStage) return t; await delay(250); } const last = await fetchTermination(terminationId, token); throw new Error( `Timed out waiting for stage "${expectedStage}" (still at "${last.currentStage}", status="${last.status}")` ); } async function approveTermination(terminationId, email, remarks) { const token = await login(email); return apiRequest( `/termination/${terminationId}/status`, 'PUT', { action: 'approve', remarks }, token ); } /** RBM+DD-ZM and SCN evaluation require two/four partial approvals before the stage advances. */ async function runJointApprovals(terminationId, actors, nextStage, adminToken, stepLabel) { log(stepLabel, `Joint approvals at ${nextStage ? 'current' : '?'} → expect "${nextStage}"`); for (const actor of actors) { const res = await approveTermination(terminationId, actor.email, actor.remarks); const message = res.message || res.data?.message || ''; log(stepLabel, `${actor.email}: ${message}`); await delay(300); const t = await fetchTermination(terminationId, adminToken); if (nextStage && t.currentStage === nextStage) { log(stepLabel, `Advanced to ${nextStage}`); return t; } } return waitUntilStage(terminationId, nextStage, adminToken); } async function runSingleApproval(terminationId, email, remarks, nextStage, adminToken, stepLabel) { const res = await approveTermination(terminationId, email, remarks); log(stepLabel, `${email}: ${res.message || 'approved'}`); await delay(300); return waitUntilStage(terminationId, nextStage, adminToken); } async function finalizeTermination(terminationId, email, nextStage, remarks, stepLabel, adminToken) { const token = await login(email); const res = await apiRequest( `/termination/${terminationId}/finalize`, 'POST', { decision: 'Approve', remarks }, token ); log(stepLabel, `${email} finalize → ${res.message || 'ok'}`); await delay(300); return waitUntilStage(terminationId, nextStage, adminToken); } async function uploadScnResponsePlaceholder(terminationId) { const token = await login(EMAILS.DD_ADMIN); const form = new FormData(); // Multer only allows PDF/images/office — use minimal PDF bytes const pdfBytes = Buffer.from( '%PDF-1.4\n1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>endobj\nxref\n0 4\ntrailer<>\nstartxref\n0\n%%EOF' ); const blob = new Blob([pdfBytes], { type: 'application/pdf' }); form.append('file', blob, 'scn-response-e2e.pdf'); form.append('remarks', 'Dealer SCN response uploaded (E2E script).'); await apiRequest(`/termination/${terminationId}/scn-response`, 'POST', form, token, true); log('SCN', 'SCN response uploaded by DD Admin'); } const STAGE_ORDER = [ STAGES.RBM, STAGES.ZBH, STAGES.DD_LEAD, STAGES.LEGAL, STAGES.DD_HEAD, STAGES.NBH, STAGES.SCN, STAGES.SCN_EVAL, STAGES.NBH_FINAL, STAGES.CCO, STAGES.CEO, STAGES.LEGAL_LETTER, STAGES.TERMINATED ]; function stageIndex(stage) { const idx = STAGE_ORDER.indexOf(stage); return idx >= 0 ? idx : 0; } function shouldRun(resumeFromStage, targetStage) { return stageIndex(resumeFromStage) <= stageIndex(targetStage); } async function runWorkflowFromStage(terminationId, resumeFromStage, adminToken, isUnethicalCategory) { let step = 2; if (isUnethicalCategory) { log(step++, 'Unethical category — workflow starts at DD Lead (RBM/ZBH not used).'); } if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.RBM)) { await runJointApprovals( terminationId, [ { email: EMAILS.RBM, remarks: 'RBM validation complete.' }, { email: EMAILS.DD_ZM, remarks: 'DD-ZM confirmed escalation.' } ], STAGES.ZBH, adminToken, step ); await delay(); } if (!isUnethicalCategory && shouldRun(resumeFromStage, STAGES.ZBH)) { await runSingleApproval(terminationId, EMAILS.ZBH, 'Strategic decision aligned.', STAGES.DD_LEAD, adminToken, step++); await delay(); } if (shouldRun(resumeFromStage, STAGES.DD_LEAD)) { await runSingleApproval( terminationId, EMAILS.DD_LEAD, isUnethicalCategory ? 'Immediate escalation — breaches documented.' : 'Breaches documented.', STAGES.LEGAL, adminToken, step++ ); await delay(); } if (shouldRun(resumeFromStage, STAGES.LEGAL)) { await runSingleApproval(terminationId, EMAILS.LEGAL, 'Case is sound.', STAGES.DD_HEAD, adminToken, step++); await delay(); } if (shouldRun(resumeFromStage, STAGES.DD_HEAD)) { await runSingleApproval(terminationId, EMAILS.DD_HEAD, 'Strategic impact assessed.', STAGES.NBH, adminToken, step++); await delay(); } if (shouldRun(resumeFromStage, STAGES.NBH)) { await runSingleApproval(terminationId, EMAILS.NBH, 'Functional teams aligned.', STAGES.SCN, adminToken, step++); await delay(); } if (shouldRun(resumeFromStage, STAGES.SCN)) { const atScn = await fetchTermination(terminationId, adminToken); if (atScn.currentStage === STAGES.SCN) { await uploadScnResponsePlaceholder(terminationId); await waitUntilStage(terminationId, STAGES.SCN_EVAL, adminToken); } await delay(); } if (shouldRun(resumeFromStage, STAGES.SCN_EVAL)) { await runJointApprovals( terminationId, [ { email: EMAILS.DD_LEAD, remarks: 'SCN response reviewed by DD Lead.' }, { email: EMAILS.ZBH, remarks: 'SCN response reviewed by ZBH.' }, { email: EMAILS.RBM, remarks: 'SCN response reviewed by RBM.' }, { email: EMAILS.DD_HEAD, remarks: 'SCN response reviewed by DD Head.' } ], STAGES.NBH_FINAL, adminToken, step ); await delay(); } if (shouldRun(resumeFromStage, STAGES.NBH_FINAL)) { await finalizeTermination(terminationId, EMAILS.NBH, STAGES.CCO, 'NBH final authorization.', step++, adminToken); await delay(); } if (shouldRun(resumeFromStage, STAGES.CCO)) { await finalizeTermination(terminationId, EMAILS.CCO, STAGES.CEO, 'CCO authorization.', step++, adminToken); await delay(); } if (shouldRun(resumeFromStage, STAGES.CEO)) { await finalizeTermination(terminationId, EMAILS.CEO, STAGES.LEGAL_LETTER, 'CEO final authorization.', step++, adminToken); await delay(); } if (shouldRun(resumeFromStage, STAGES.LEGAL_LETTER)) { await runSingleApproval( terminationId, EMAILS.LEGAL, 'Termination letter issued.', STAGES.TERMINATED, adminToken, step++ ); await delay(); } const afterLegal = await fetchTermination(terminationId, adminToken); if (afterLegal.currentStage === STAGES.TERMINATED) { log(step, `Terminated (status: ${afterLegal.status}). Use Push to F&F in UI when LWD is reached.`); } } async function run() { try { console.log('--- STARTING DEALER TERMINATION E2E FLOW (UI-aligned) ---'); const adminToken = await login(EMAILS.DD_ADMIN); const dealersRes = await apiRequest('/dealer', 'GET', null, adminToken); const targetDealer = dealersRes.data[0]; if (!targetDealer) throw new Error('No dealer profiles found. Run seed first.'); console.log(`Targeting Dealer: ${targetDealer.legalName} (${targetDealer.id})`); let terminationId = args.terminationId; const isUnethical = String(args.category || '').trim().toLowerCase().includes('unethical'); if (!terminationId) { log(1, 'Creating termination (ASM)...'); const asmToken = await login(EMAILS.ASM); // Backend (termination.controller.ts) requires at least one .ppt/.pptx // file for non-Super-Admin initiators, sent as multipart "files". const form = new FormData(); form.append('dealerId', targetDealer.id); form.append('category', args.category || 'Performance'); form.append('reason', args.reason || 'Consistently failed to meet commitment targets.'); form.append('proposedLwd', new Date().toISOString().split('T')[0]); form.append('comments', 'E2E termination — follows UI stage order (no stacked partial approvals).'); const dummyPptxBytes = Buffer.from('E2E placeholder presentation'); const dummyPptxBlob = new Blob([dummyPptxBytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }); form.append('files', dummyPptxBlob, 'e2e-termination-presentation.pptx'); const createRes = await apiRequest('/termination', 'POST', form, asmToken, true); terminationId = createRes.termination.id; log(1, `Created: ${terminationId} (${args.category || 'Performance'})`); } else { log(1, `Resuming: ${terminationId}`); } await delay(); let termination = await fetchTermination(terminationId, adminToken); log('INFO', `Starting stage: ${termination.currentStage} | status: ${termination.status}`); const resumeFrom = STAGE_ORDER.includes(termination.currentStage) ? termination.currentStage : isUnethical ? STAGES.DD_LEAD : STAGES.RBM; if (termination.currentStage === STAGES.TERMINATED || termination.status?.includes('F&F')) { log('SKIP', 'Already terminated / in F&F — workflow steps skipped.'); } else { await runWorkflowFromStage(terminationId, resumeFrom, adminToken, isUnethical); } if (!SHOULD_SKIP_CLEARANCES) { termination = await fetchTermination(terminationId, adminToken); const fnfId = termination.fnfSettlement?.id; if (!fnfId) { log('F&F', 'No FnF record yet (expected until Push to F&F after LWD). Skipping clearances.'); } else { log('F&F', 'Running department clearances...'); const departments = [ { name: 'Warranty Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'No pending claims.' }, { name: 'Sales Department', status: 'Cleared', amount: 0, type: 'Receivable', remarks: 'Allocations transferred.' } ]; for (const dept of departments) { await apiRequest( `/termination/${terminationId}/clearance`, 'PUT', dept, adminToken ); await delay(200); } } } const finalDetails = await fetchTermination(terminationId, adminToken); console.log(`[FINAL] stage=${finalDetails.currentStage} status=${finalDetails.status}`); const userRes = await apiRequest('/admin/users', 'GET', null, adminToken); const dealerUser = userRes.data?.find((u) => u.dealerId === targetDealer.id); if (dealerUser && !dealerUser.isActive && dealerUser.status === 'inactive') { console.log(`[VERIFICATION] Dealer portal user ${dealerUser.email} deactivated.`); } else if (finalDetails.currentStage === STAGES.TERMINATED) { console.log('[VERIFICATION] Terminated — dealer deactivation may occur at Legal Letter stage.'); } console.log('\n--- TERMINATION E2E COMPLETE ---'); process.exit(0); } catch (error) { console.error('Workflow failed:', error.message); process.exit(1); } } run();