Dealer_Onboarding_Backend/src/services/RelocationWorkflowService.ts

217 lines
9.2 KiB
TypeScript

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<string, string> = {
[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<boolean> {
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;
}
}