import db from '../database/models/index.js'; const { RelocationRequest, AuditLog, User, RequestParticipant, Outlet } = db; import { AUDIT_ACTIONS, RELOCATION_STAGES, ROLES, REQUEST_TYPES } from '../common/config/constants.js'; import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; import logger from '../common/utils/logger.js'; import { NotificationService } from './NotificationService.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; export class RelocationWorkflowService { /** * Standardized method to transition a relocation request status */ static async transitionRelocation(request: any, targetStatus: string, userId: string | null = null, metadata: any = {}) { const previousStatus = request.status; const { reason, stage, progressPercentage, action, auditAction } = metadata; const updateData: any = { status: targetStatus, updatedAt: new Date() }; // Update stage if provided and valid if (stage && Object.values(RELOCATION_STAGES).includes(stage)) { updateData.currentStage = stage; } // Update progress percentage if explicitly provided if (progressPercentage !== undefined) { updateData.progressPercentage = progressPercentage; } const sourceStage = request.currentStage; // 1. Update Request Record await request.update(updateData); // 2. Update Timeline (JSON array) const user = userId ? await User.findByPk(userId) : null; const timelineEntry = { stage: sourceStage, // Store the stage where the action happened targetStage: stage || targetStatus, timestamp: new Date(), user: user ? user.fullName : 'System', action: action || `Transitioned to ${targetStatus}`, remarks: reason || '' }; const updatedTimeline = [...(request.timeline || []), timelineEntry]; await request.update({ timeline: updatedTimeline }); const toStage = updateData.currentStage; if (toStage && toStage !== sourceStage) { await syncSlaOnStageTransition({ entityType: 'relocation', entityId: request.id, fromStage: sourceStage, toStage }); } // 3. Create Audit Log using standardized mapper const { actionType } = metadata; let resolvedAuditAction = getOffboardingAuditAction(actionType || action || 'Approved', REQUEST_TYPES.RELOCATION); if (auditAction) { resolvedAuditAction = auditAction; } else if (action === 'REJECT') { resolvedAuditAction = AUDIT_ACTIONS.REJECTED; } else if (action === 'REVOKE') { resolvedAuditAction = AUDIT_ACTIONS.RELOCATION_REVOKED; } else if (action === 'SEND_BACK') { resolvedAuditAction = AUDIT_ACTIONS.RELOCATION_SENT_BACK; } await db.RelocationAudit.create({ userId: userId, relocationRequestId: request.id, action: formatOffboardingAction(resolvedAuditAction), remarks: reason || '', details: { status: targetStatus, stage: sourceStage, targetStage: stage || targetStatus } }); // 4. Create Worknote for standardized communication trail if (reason && userId) { try { // Prepend action to remarks for better context in Work Notes const actionPrefix = action ? `${formatOffboardingAction(action)}: ` : ''; await writeWorkflowActivityWorknote({ requestId: request.id, requestType: 'relocation', userId: userId, noteText: `${actionPrefix}${reason}`, noteType: (action === 'SEND_BACK' || action === 'SEND_BACK_TO_ASM') ? 'workflow' : 'internal' }); } catch (wnErr) { logger.error('[RelocationWorkflowService] failed to write worknote:', wnErr); } } console.log(`[RelocationWorkflowService] Transitioned Request ${request.requestId} to ${targetStatus}`); await request.reload(); const dealerUser = await User.findByPk(request.dealerId, { attributes: ['id', 'email', 'fullName'] }); if (dealerUser?.email) { const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; const stageLabel = request.currentStage || request.status || targetStatus; const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); await notifyStakeholdersOnTransition( request.id, REQUEST_TYPES.RELOCATION, stageLabel, { code: request.requestId, dealerName: dealerUser.fullName || 'Dealer', dealerId: dealerUser.id, actionUserFullName: user ? user.fullName : 'System', action: action || `Transitioned to ${targetStatus}`, remarks: reason || 'N/A', link: `${portalBase}/relocation-requests/${request.id}` } ); } return request; } /** * Checks if a user is authorized to perform an action in the current stage */ static async canUserAction(request: any, user: any) { if (!user) return false; // Super Admin bypass if (user.roleCode === ROLES.SUPER_ADMIN) return true; const stageMapping: Record = { [RELOCATION_STAGES.ASM_REVIEW]: ROLES.ASM, 'DD Admin Review': ROLES.ASM, // Legacy/alias mapping for older requests [RELOCATION_STAGES.RBM_REVIEW]: ROLES.RBM, [RELOCATION_STAGES.DD_ZM_REVIEW]: ROLES.DD_ZM, [RELOCATION_STAGES.ZBH_REVIEW]: ROLES.ZBH, [RELOCATION_STAGES.DD_LEAD_REVIEW]: ROLES.DD_LEAD, [RELOCATION_STAGES.NBH_APPROVAL]: ROLES.NBH, [RELOCATION_STAGES.LEGAL_CLEARANCE]: ROLES.LEGAL_ADMIN }; const requiredRole = stageMapping[request.currentStage]; if (!requiredRole) return false; // Role-based check if (user.roleCode !== requiredRole) return false; // Stage-specific participant assignment enforcement: actor must be mapped on this request. const participant = await RequestParticipant.findOne({ where: { requestId: request.id, requestType: REQUEST_TYPES.RELOCATION, userId: user.id }, attributes: ['id'] }); if (participant) return true; const anyParticipant = await RequestParticipant.findOne({ where: { requestId: request.id, requestType: REQUEST_TYPES.RELOCATION }, attributes: ['id'] }); // Backward compatibility for legacy requests created before participant auto-assignment. if (!anyParticipant) return true; // Match relocation.controller getRequests: territory actors must act even if RequestParticipant // omitted them (e.g. ASM was taken only from Dealer.asmId while list uses district.asmId). return await RelocationWorkflowService.userMatchesRelocationOutletHierarchy(request.id, user); } /** Same outlet hierarchy checks as internal-user filter on relocation list (ASM/RBM/DD-ZM/ZBH). */ static async userMatchesRelocationOutletHierarchy(requestId: string, user: any): Promise { const row = await RelocationRequest.findByPk(requestId, { include: [ { model: User, as: 'dealer', attributes: ['id'], include: [{ model: db.Dealer, as: 'dealerProfile', attributes: ['asmId'] }] }, { model: Outlet, as: 'outlet', include: [ { model: db.District, as: 'district', include: [ { model: db.Region, as: 'region' }, { model: db.Zone, as: 'zone' } ] } ] } ] }); if (!row) return false; const outlet = (row as any).outlet; const district = outlet?.district; if (!district) return false; const uid = user.id; const rc = user.roleCode; const outletLevelAsmId = (row as any).dealer?.dealerProfile?.asmId ?? null; if (rc === ROLES.ASM && (outletLevelAsmId === uid || district.asmId === uid)) return true; if (rc === ROLES.RBM && district.region?.rbmId === uid) return true; if (rc === ROLES.DD_ZM && district.zmId === uid) return true; if (rc === ROLES.ZBH && district.zone?.zbhId === uid) return true; return false; } }