diff --git a/src/common/utils/email.service.ts b/src/common/utils/email.service.ts index 74ab172..80d70e8 100644 --- a/src/common/utils/email.service.ts +++ b/src/common/utils/email.service.ts @@ -11,6 +11,9 @@ import { getSmtpConfig, isSmtpEnabled } from '../../services/smtpConfig.service.js'; +import { getFrontendBaseUrl } from './frontendUrl.js'; + +export { getFrontendBaseUrl } from './frontendUrl.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -208,7 +211,7 @@ export const sendOpportunityEmail = async ( location: string, applicationId: string ) => { - const link = `http://localhost:5173/questionnaire/${applicationId}`; + const link = `${getFrontendBaseUrl()}/questionnaire/${applicationId}`; await sendEmail(to, 'Action Required: Royal Enfield Dealership Opportunity', 'OPPORTUNITY', { applicantName, location, @@ -286,7 +289,7 @@ export const sendShortlistedEmail = async ( location: string, applicationId: string ) => { - const portalLink = 'http://localhost:5173/login'; + const portalLink = `${getFrontendBaseUrl()}/login`; await sendEmail(to, `Congratulations! You are Shortlisted: ${applicationId}`, 'APPLICANT_SHORTLISTED', { applicantName, location, diff --git a/src/common/utils/frontendUrl.ts b/src/common/utils/frontendUrl.ts new file mode 100644 index 0000000..a6219a6 --- /dev/null +++ b/src/common/utils/frontendUrl.ts @@ -0,0 +1,7 @@ +/** + * Portal base URL for emails, WhatsApp, SLA alerts, and notifications. + * Set `FRONTEND_URL` in backend `.env` (e.g. https://dealeronboarding-uat.royalenfield.com). + */ +export function getFrontendBaseUrl(): string { + return (process.env.FRONTEND_URL || 'http://localhost:5173').replace(/\/$/, ''); +} diff --git a/src/common/utils/socket.ts b/src/common/utils/socket.ts index bfe1a1f..1e672b4 100644 --- a/src/common/utils/socket.ts +++ b/src/common/utils/socket.ts @@ -1,6 +1,7 @@ import { Server as SocketServer } from 'socket.io'; import { Server as HTTPServer } from 'http'; import logger from './logger.js'; +import { getFrontendBaseUrl } from './frontendUrl.js'; let io: SocketServer | null = null; @@ -11,7 +12,7 @@ let io: SocketServer | null = null; export const initSocket = (httpServer: HTTPServer) => { io = new SocketServer(httpServer, { cors: { - origin: process.env.FRONTEND_URL || 'http://localhost:5173', + origin: getFrontendBaseUrl(), methods: ['GET', 'POST', 'PUT'], credentials: true } diff --git a/src/common/utils/workflow-email-notifications.ts b/src/common/utils/workflow-email-notifications.ts index 9675fb4..2917ffb 100644 --- a/src/common/utils/workflow-email-notifications.ts +++ b/src/common/utils/workflow-email-notifications.ts @@ -3,6 +3,7 @@ import { Op } from 'sequelize'; import { sendEmail } from './email.service.js'; import { NotificationService } from '../../services/NotificationService.js'; import { +import { getFrontendBaseUrl } from './frontendUrl.js'; APPLICATION_STAGES, TERMINATION_STAGES, CONSTITUTIONAL_STAGES, @@ -13,8 +14,6 @@ import { const { RequestParticipant, User, Outlet, District, Dealer } = db; -const frontendBase = () => process.env.FRONTEND_URL || 'http://localhost:5173'; - /** Dealer acknowledgement + internal reviewers after resignation is created. */ export async function notifyResignationSubmittedEmails(resignation: any): Promise { const dealerUser = await User.findByPk(resignation.dealerId, { @@ -22,7 +21,7 @@ export async function notifyResignationSubmittedEmails(resignation: any): Promis }); if (!dealerUser?.email) return; - const base = frontendBase(); + const base = getFrontendBaseUrl(); const resignationCode = resignation.resignationId || resignation.id; const lwd = resignation.lastOperationalDateSales || @@ -99,7 +98,7 @@ export async function notifyConstitutionalSubmittedEmails(request: any, dealerDi include: [{ model: User, as: 'user', attributes: ['id', 'email', 'fullName', 'mobileNumber'] }] }); - const base = frontendBase(); + const base = getFrontendBaseUrl(); const link = `${base}/constitutional-change/${request.id}`; for (const p of participants) { @@ -128,7 +127,7 @@ export async function notifyRelocationSubmittedEmails( request: any, submitter: { email: string; fullName?: string | null } ): Promise { - const base = frontendBase(); + const base = getFrontendBaseUrl(); const code = request.requestId || request.id; const dealerName = submitter.fullName?.trim() || 'Dealer'; diff --git a/src/modules/assessment/assessment.controller.ts b/src/modules/assessment/assessment.controller.ts index ff52de7..df5cc32 100644 --- a/src/modules/assessment/assessment.controller.ts +++ b/src/modules/assessment/assessment.controller.ts @@ -339,7 +339,7 @@ const processStageDecision = async (params: { // we still need to trigger notifications for the NEXT person in the sequence. try { const { notifyStakeholdersOnTransition } = await import('../../common/utils/workflow-email-notifications.js'); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); await notifyStakeholdersOnTransition( application.id, @@ -583,7 +583,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { type, scheduledAt: formatIST(scheduledDateObj), meetLink, - appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + appLink: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: applicantPhone, ctaLabel: 'View application' } @@ -710,7 +710,7 @@ export const scheduleInterview = async (req: AuthRequest, res: Response) => { type, scheduledAt: formatIST(scheduledDateObj), meetLink, - appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + appLink: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: pPhone || '', ctaLabel: 'View application' } @@ -813,7 +813,7 @@ export const updateInterview = async (req: AuthRequest, res: Response) => { type, scheduledAt: formatIST(interview.scheduleDate), meetLink, - appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + appLink: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: application.mobileNumber || '', ctaLabel: 'View Schedule' } @@ -840,7 +840,7 @@ export const updateInterview = async (req: AuthRequest, res: Response) => { type, scheduledAt: formatIST(interview.scheduleDate), meetLink, - appLink: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + appLink: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: panelist.mobileNumber || '', ctaLabel: 'Open Assessment' } @@ -1043,6 +1043,7 @@ export const submitLevel2Feedback = async (req: AuthRequest, res: Response) => { // --- AI Summary --- import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; export const generateAiSummary = async (req: AuthRequest, res: Response) => { try { diff --git a/src/modules/collaboration/collaboration.controller.ts b/src/modules/collaboration/collaboration.controller.ts index 5560d7c..c2e4a6a 100644 --- a/src/modules/collaboration/collaboration.controller.ts +++ b/src/modules/collaboration/collaboration.controller.ts @@ -16,6 +16,7 @@ import { getIO } from '../../common/utils/socket.js'; import * as InAppPushNotificationService from '../../common/utils/notification.service.js'; import { NotificationService as EmailNotificationService } from '../../services/NotificationService.js'; import logger from '../../common/utils/logger.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; // --- Helpers --- const getDocumentModel = (requestType: string) => { @@ -190,7 +191,7 @@ export const addWorknote = async (req: AuthRequest, res: Response) => { }); } - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); let worknoteLink = `${portalBase}/dashboard`; let applicationIdLabel = String(resolvedId).slice(0, 12); let dealerNameLabel = 'Record'; diff --git a/src/modules/eor/eor.controller.ts b/src/modules/eor/eor.controller.ts index c2b576f..4655aea 100644 --- a/src/modules/eor/eor.controller.ts +++ b/src/modules/eor/eor.controller.ts @@ -5,6 +5,7 @@ const { EorChecklist, EorChecklistItem, OnboardingDocument, RelocationDocument } import { AuthRequest } from '../../types/express.types.js'; import { NotificationService } from '../../services/NotificationService.js'; import { ROLES } from '../../common/config/constants.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; /** Default EOR rows for relocation (SRS 12.2.8) — must stay aligned with relocation required-doc labels. */ export const RELOCATION_EOR_DEFAULT_ITEMS = [ @@ -355,7 +356,7 @@ export const submitAudit = async (req: AuthRequest, res: Response) => { placeholders: { applicantName: app?.applicantName || '', applicationId: app?.applicationId || '', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${checklist.applicationId}`, + link: `${getFrontendBaseUrl()}/applications/${checklist.applicationId}`, ctaLabel: 'View Application' } }).catch((e: any) => console.error('[EOR] Completion notify failed:', e)); @@ -379,7 +380,7 @@ export const submitAudit = async (req: AuthRequest, res: Response) => { placeholders: { applicantName: '', applicationId: checklist.relocationId, - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/relocation-requests/${checklist.relocationId}`, + link: `${getFrontendBaseUrl()}/relocation-requests/${checklist.relocationId}`, ctaLabel: 'View Request' } }).catch((e: any) => console.error('[EOR] Relocation notify failed:', e)); diff --git a/src/modules/fdd/fdd.controller.ts b/src/modules/fdd/fdd.controller.ts index 90fe9e1..3dcf6d1 100644 --- a/src/modules/fdd/fdd.controller.ts +++ b/src/modules/fdd/fdd.controller.ts @@ -8,6 +8,7 @@ import { WorkflowService } from '../../services/WorkflowService.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from '../../services/applicationAuditLog.service.js'; import { NotificationService } from '../../services/NotificationService.js'; import { formatDueDateDaysFromNow, DEFAULT_FDD_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; export const getAssignment = async (req: Request, res: Response) => { try { @@ -105,14 +106,14 @@ export const assignAgency = async (req: AuthRequest, res: Response) => { applicationId: application.applicationId, dealerName: application.applicantName || '', participantType: 'FDD Partner', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + link: `${getFrontendBaseUrl()}/applications/${application.id}`, ctaLabel: 'View Assignment', phone: phone || '' } }).catch((e: any) => console.error('[FDD] Agency notify failed:', e)); } - const portalBaseFdd = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBaseFdd = getFrontendBaseUrl(); if (application.email) { const applicantAcct = await db.User.findOne({ where: { email: application.email }, @@ -221,7 +222,7 @@ export const uploadReport = async (req: AuthRequest, res: Response) => { dealerName: application.applicantName || '', requestId: application.applicationId, targetStage: 'FDD Review', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + link: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: '' } }).catch((e: any) => console.error('[FDD] Finance/Admin notify failed:', e)); diff --git a/src/modules/loa/loa.controller.ts b/src/modules/loa/loa.controller.ts index 405561c..627e5c3 100644 --- a/src/modules/loa/loa.controller.ts +++ b/src/modules/loa/loa.controller.ts @@ -6,6 +6,7 @@ import { AuthRequest } from '../../types/express.types.js'; import { AUDIT_ACTIONS, APPLICATION_STATUS, APPLICATION_STAGES, ROLES } from '../../common/config/constants.js'; import { WorkflowService } from '../../services/WorkflowService.js'; import { NotificationService } from '../../services/NotificationService.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; const LOA_STAGE_CODE = 'LOA_APPROVAL'; @@ -101,7 +102,7 @@ export const createRequest = async (req: AuthRequest, res: Response) => { dealerName: application.applicantName || application.applicationId, requestId: application.applicationId, targetStage: 'LOA Approval', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + link: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: phone || '' } }).catch((e: any) => console.error('[LOA] DD-Head notify failed:', e)); @@ -243,7 +244,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { placeholders: { applicantName: app?.applicantName || '', applicationId: app?.applicationId || '', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`, + link: `${getFrontendBaseUrl()}/applications/${request.applicationId}`, ctaLabel: 'View Application' } }).catch((e: any) => console.error('[LOA] team notify failed:', e)); @@ -254,7 +255,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { include: [{ model: DealerCode, as: 'dealerCode', required: false }] }); if (appFull?.email) { - const portalBaseLoa = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBaseLoa = getFrontendBaseUrl(); const dc = (appFull as any).dealerCode; const dealerCodeStr = dc?.salesCode || dc?.serviceCode || 'Available on portal after SAP sync'; const applicantAcct = await User.findOne({ @@ -324,7 +325,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { dealerName: '', requestId, targetStage: 'NBH LOA Approval', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`, + link: `${getFrontendBaseUrl()}/applications/${request.applicationId}`, phone: phone || '' } }).catch((e: any) => console.error('[LOA] NBH notify failed:', e)); diff --git a/src/modules/loi/loi.controller.ts b/src/modules/loi/loi.controller.ts index b83c837..eb2bcbc 100644 --- a/src/modules/loi/loi.controller.ts +++ b/src/modules/loi/loi.controller.ts @@ -7,6 +7,7 @@ import { WorkflowService } from '../../services/WorkflowService.js'; import { NotificationService } from '../../services/NotificationService.js'; import { sendEmail } from '../../common/utils/email.service.js'; import { formatDueDateDaysFromNow, DEFAULT_STATUTORY_DOCUMENT_CHECKLIST } from '../../constants/onboarding-email-defaults.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; const LOI_STAGE_CODE = 'LOI_APPROVAL'; @@ -96,7 +97,7 @@ export const acknowledgeRequest = async (req: AuthRequest, res: Response) => { progressPercentage: 90 }); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const deposit = (await SecurityDeposit.findOne({ where: { applicationId: application.id, depositType: 'SECURITY_DEPOSIT' } @@ -182,7 +183,7 @@ export const createRequest = async (req: AuthRequest, res: Response) => { dealerName: application.applicantName || application.applicationId, requestId: application.applicationId, targetStage: 'LOI Approval', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + link: `${getFrontendBaseUrl()}/applications/${application.id}`, phone: phone || '' } }).catch((e: any) => console.error('[LOI] DD-Head notify failed:', e)); @@ -336,7 +337,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { placeholders: { dealerName: application2?.applicantName || '', requestId: application2?.applicationId || '', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`, + link: `${getFrontendBaseUrl()}/applications/${request.applicationId}`, ctaLabel: 'View Application' } }).catch((e: any) => console.error('[LOI] finance/admin notify failed:', e)); @@ -358,7 +359,7 @@ export const approveRequest = async (req: AuthRequest, res: Response) => { dealerName: '', requestId: String(request.applicationId), targetStage: 'NBH LOI Approval', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${request.applicationId}`, + link: `${getFrontendBaseUrl()}/applications/${request.applicationId}`, phone: phone || '' } }).catch((e: any) => console.error('[LOI] NBH notify failed:', e)); @@ -474,7 +475,7 @@ export const generateDocument = async (req: AuthRequest, res: Response) => { { applicantName: application.applicantName || 'Applicant', applicationId: application.applicationId, - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/prospect-login`, + link: `${getFrontendBaseUrl()}/prospect-login`, ctaLabel: 'View Your Application' } ).catch((e: any) => console.error('[LOI] Applicant email failed:', e)); @@ -493,7 +494,7 @@ export const generateDocument = async (req: AuthRequest, res: Response) => { placeholders: { applicantName: application.applicantName || '', applicationId: application.applicationId, - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${application.id}`, + link: `${getFrontendBaseUrl()}/applications/${application.id}`, ctaLabel: 'View Application' } }).catch((e: any) => console.error('[LOI] stakeholder notify failed:', e)); @@ -501,7 +502,7 @@ export const generateDocument = async (req: AuthRequest, res: Response) => { } if (application.email) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); await NotificationService.notify(null, application.email, { title: `Statutory documents — ${application.applicationId}`, message: 'Please upload statutory and compliance documents on the Dealer Portal.', diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts index f3f0de5..6647140 100644 --- a/src/modules/onboarding/onboarding.controller.ts +++ b/src/modules/onboarding/onboarding.controller.ts @@ -217,6 +217,10 @@ export const submitApplication = async (req: AuthRequest, res: Response) => { applicantName: displayApplicantName, location: displayLocation, applicationId, + link: isOpportunityAvailable + ? `${getFrontendBaseUrl()}/questionnaire/${applicationId}` + : undefined, + ctaLabel: isOpportunityAvailable ? 'Complete Questionnaire' : undefined, phone } }).catch((err: any) => console.error('[Onboarding] WhatsApp ack failed:', err)); @@ -722,7 +726,7 @@ export const uploadDocuments = async (req: any, res: Response) => { }); if (application.email) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); await NotificationService.notify(null, application.email, { title: `Document received — ${application.applicationId}`, message: `We received your upload: ${documentType}.`, @@ -846,7 +850,7 @@ export const bulkShortlist = async (req: AuthRequest, res: Response) => { application.applicationId ).catch(err => console.error('Failed to send shortlist email:', err)); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const applicantUser = await User.findOne({ where: { email: application.email }, attributes: ['id', 'mobileNumber'] @@ -1149,7 +1153,7 @@ export const assignArchitectureTeam = async (req: AuthRequest, res: Response) => }); const architect = await User.findByPk(targetUserId, { attributes: ['fullName'] }); - const portalBaseArch = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBaseArch = getFrontendBaseUrl(); if (application.email) { await NotificationService.notify(null, application.email, { title: `Architecture & site inputs — ${application.applicationId}`, @@ -1215,6 +1219,7 @@ export const updateArchitectureStatus = async (req: AuthRequest, res: Response) }; import { ExternalMocksService } from '../../common/utils/externalMocks.service.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; export const generateDealerCodes = async (req: AuthRequest, res: Response) => { try { @@ -1746,7 +1751,7 @@ export const sendBulkDocumentReminders = async (req: AuthRequest, res: Response) applicationId: app.applicationId, pendingDocuments: pendingDocuments || undefined, dueDate: dueDate || formatDueDateDaysFromNow(7), - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/applications/${app.id}` + link: `${getFrontendBaseUrl()}/applications/${app.id}` }); await safeAuditLogCreate({ userId: req.user?.id || null, @@ -1798,7 +1803,7 @@ export const sendBulkLoiAckReminders = async (req: AuthRequest, res: Response) = await NotificationService.sendLoiAcknowledgementReminder(app.email, app.phone, app.applicantName, { applicationId: app.applicationId, dueDate: dueDate || formatDueDateDaysFromNow(7), - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/prospect-login` + link: `${getFrontendBaseUrl()}/prospect-login` }); sent++; } @@ -1842,7 +1847,7 @@ export const rejectOnboardingDocument = async (req: AuthRequest, res: Response) await doc.update({ status: 'rejected' }); if (application.email) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); await NotificationService.notify(null, application.email, { title: `Document re-upload required — ${application.applicationId}`, message: `A document was not accepted: ${doc.documentType}.`, diff --git a/src/modules/self-service/constitutional.controller.ts b/src/modules/self-service/constitutional.controller.ts index e087efa..dbaf200 100644 --- a/src/modules/self-service/constitutional.controller.ts +++ b/src/modules/self-service/constitutional.controller.ts @@ -23,6 +23,7 @@ import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constant import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; import { notifyConstitutionalSubmittedEmails } from '../../common/utils/workflow-email-notifications.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; const STRUCTURE_TARGET_VALUES = new Set( CONSTITUTIONAL_STRUCTURE_TARGET_OPTIONS.map((o) => o.value as string) @@ -674,7 +675,7 @@ export const takeAction = async (req: AuthRequest, res: Response) => { }] }); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); for (const p of remainingParticipants) { const u = (p as any).user; diff --git a/src/modules/self-service/resignation.controller.ts b/src/modules/self-service/resignation.controller.ts index 8c3d063..311e42f 100644 --- a/src/modules/self-service/resignation.controller.ts +++ b/src/modules/self-service/resignation.controller.ts @@ -23,6 +23,7 @@ import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { OFFBOARDING_ACTIONS } from '../../common/config/constants.js'; import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorknote.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; // Removed generateResignationId and moved to NomenclatureService const resolveResignationUuid = async (id: string) => { @@ -330,7 +331,7 @@ export const uploadResignationDocument = async (req: AuthRequest, res: Response, // SRS §7.5.2 — When Legal uploads the acceptance letter, notify DD-Admin and ASM // so they can communicate official closure to the dealer (field hierarchy). if (stage === RESIGNATION_STAGES.LEGAL && resignation.currentStage === RESIGNATION_STAGES.LEGAL) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const resignationCode = resignation.resignationId || resignation.id; setImmediate(() => notifyStakeholdersOnTransition( diff --git a/src/modules/settlement/settlement.controller.ts b/src/modules/settlement/settlement.controller.ts index 6af0096..cd0f251 100644 --- a/src/modules/settlement/settlement.controller.ts +++ b/src/modules/settlement/settlement.controller.ts @@ -11,8 +11,9 @@ import { writeWorkflowActivityWorknote } from '../../common/utils/workflowWorkno import { resolveEntityUuidByType } from '../../common/utils/requestResolver.js'; import { NomenclatureService } from '../../common/utils/nomenclature.js'; import { NotificationService } from '../../services/NotificationService.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; -const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; +const portalBase = getFrontendBaseUrl(); const LINE_ITEM_DESCRIPTION_PREFIX = { DEPARTMENT_CLAIM: '[DEPARTMENT_CLAIM]', diff --git a/src/modules/termination/termination.controller.ts b/src/modules/termination/termination.controller.ts index 8371ef6..b208d56 100644 --- a/src/modules/termination/termination.controller.ts +++ b/src/modules/termination/termination.controller.ts @@ -26,6 +26,7 @@ import { OFFBOARDING_ACTIONS, REQUEST_TYPES } from '../../common/config/constant import { validateOffboardingAction, getPreviousStage } from '../../common/utils/offboardingWorkflow.utils.js'; import { NotificationService } from '../../services/NotificationService.js'; import { sendEmail } from '../../common/utils/email.service.js'; +import { getFrontendBaseUrl } from '../../common/utils/frontendUrl.js'; const resolveTerminationUuid = async (id: string) => { const { resolvedId } = await resolveEntityUuidByType(db as any, id, 'termination'); @@ -101,7 +102,7 @@ export const createTermination = async (req: AuthRequest, res: Response, next: N dealerName: '', requestId: termination.requestId, reason: reason || '', - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/termination/${termination.id}`, + link: `${getFrontendBaseUrl()}/termination/${termination.id}`, ctaLabel: 'Review Request', phone: phone || '' } @@ -758,7 +759,7 @@ export const issueScn = async (req: AuthRequest, res: Response, next: NextFuncti { dealerName: dealerUser.fullName || 'Dealer', requestId: termination.requestId, - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/dealer-termination/${termination.id}`, + link: `${getFrontendBaseUrl()}/dealer-termination/${termination.id}`, ctaLabel: 'View Notice' } ).catch((e: any) => logger.error('[Termination] SCN email to dealer failed:', e)); @@ -777,7 +778,7 @@ export const issueScn = async (req: AuthRequest, res: Response, next: NextFuncti placeholders: { dealerName: dealerUser?.fullName || '', requestId: termination.requestId, - link: `${process.env.FRONTEND_URL || 'http://localhost:5173'}/termination/${termination.id}`, + link: `${getFrontendBaseUrl()}/termination/${termination.id}`, ctaLabel: 'View Case' } }).catch((e: any) => logger.error('[Termination] SCN admin/legal notify failed:', e)); diff --git a/src/server.ts b/src/server.ts index c36c784..6663520 100644 --- a/src/server.ts +++ b/src/server.ts @@ -44,6 +44,7 @@ import terminationRoutes from './modules/termination/termination.routes.js'; // Import common middleware & utils import errorHandler from './common/middleware/errorHandler.js'; import logger from './common/utils/logger.js'; +import { getFrontendBaseUrl } from './common/utils/frontendUrl.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -63,14 +64,14 @@ app.use(helmet({ contentSecurityPolicy: { directives: { ...helmet.contentSecurityPolicy.getDefaultDirectives(), - "frame-ancestors": ["'self'", process.env.FRONTEND_URL || 'http://localhost:5173'], + "frame-ancestors": ["'self'", getFrontendBaseUrl()], }, }, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: { policy: "cross-origin" } })); const allowedOrigins = new Set([ - process.env.FRONTEND_URL || 'http://localhost:5173', + getFrontendBaseUrl(), 'http://localhost:5173' ]); app.use(cors({ diff --git a/src/services/ConstitutionalWorkflowService.ts b/src/services/ConstitutionalWorkflowService.ts index 0cc7e64..d1067a8 100644 --- a/src/services/ConstitutionalWorkflowService.ts +++ b/src/services/ConstitutionalWorkflowService.ts @@ -5,6 +5,7 @@ import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote. import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { mapConstitutionalChangeTypeToDealerProfile } from '../common/utils/constitutionalNormalize.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export class ConstitutionalWorkflowService { private static normalizeDocLabel(input: string): string { @@ -197,7 +198,7 @@ export class ConstitutionalWorkflowService { const dealerUser = await db.User.findByPk(request.dealerId, { attributes: ['id', 'email', 'fullName'] }); if (dealerUser?.email) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const remarkText = String(remarks ?? '').trim() || 'N/A'; const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 34b2395..5e10dd0 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -1,4 +1,5 @@ import { sendEmail } from '../common/utils/email.service.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; import db from '../database/models/index.js'; const { Notification, PushSubscription } = db; @@ -147,7 +148,6 @@ export class NotificationService { applicantName: string, options?: { location?: string; link?: string } ) { - const base = process.env.FRONTEND_URL || 'http://localhost:5173'; await this.notify(null, email, { title: 'Action Required: Complete your Dealership Questionnaire', message: `Hi ${applicantName}, please complete the questionnaire to proceed with your application.`, @@ -157,8 +157,8 @@ export class NotificationService { applicantName, phone, location: options?.location ?? '', - link: options?.link ?? `${base}/login`, - ctaLabel: 'Complete Now' + link: options?.link ?? `${getFrontendBaseUrl()}/prospect-login`, + ctaLabel: 'Complete Questionnaire' } }); } @@ -172,7 +172,6 @@ export class NotificationService { applicantName: string, options: { applicationId: string; pendingDocuments?: string; dueDate?: string; link?: string } ) { - const base = process.env.FRONTEND_URL || 'http://localhost:5173'; const channels: ('email' | 'whatsapp')[] = ['email']; if (phone) channels.push('whatsapp'); await this.notify(null, email, { @@ -187,7 +186,7 @@ export class NotificationService { options.pendingDocuments || 'Please sign in to the Dealer Portal to view your personalised document checklist.', dueDate: options.dueDate || 'within seven calendar days', - link: options.link || `${base}/applications`, + link: options.link || `${getFrontendBaseUrl()}/applications`, ctaLabel: 'Upload documents', phone: phone || '' } @@ -203,7 +202,6 @@ export class NotificationService { applicantName: string, options: { applicationId: string; link?: string; dueDate?: string } ) { - const base = process.env.FRONTEND_URL || 'http://localhost:5173'; const channels: ('email' | 'whatsapp')[] = ['email']; if (phone) channels.push('whatsapp'); await this.notify(null, email, { @@ -215,7 +213,7 @@ export class NotificationService { applicantName, applicationId: options.applicationId, dueDate: options.dueDate || 'within seven calendar days', - link: options.link || `${base}/prospect-login`, + link: options.link || `${getFrontendBaseUrl()}/prospect-login`, ctaLabel: 'Acknowledge LOI', phone: phone || '' } diff --git a/src/services/OffboardingLwdReminderService.ts b/src/services/OffboardingLwdReminderService.ts index bcd17d9..7bfc6b1 100644 --- a/src/services/OffboardingLwdReminderService.ts +++ b/src/services/OffboardingLwdReminderService.ts @@ -13,6 +13,7 @@ import { msUntilLwdMorning } from '../common/utils/offboardingLwd.js'; import logger from '../common/utils/logger.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; const FNF_PUSH_ROLES = [ROLES.DD_LEAD, ROLES.DD_HEAD, ROLES.NBH, ROLES.DD_ADMIN, ROLES.SUPER_ADMIN]; @@ -45,7 +46,7 @@ export class OffboardingLwdReminderService { lwd: Date | string; detailPath: string; }): Promise { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const lwdStr = formatLwdDisplay(params.lwd); const link = `${portalBase}${params.detailPath}`; diff --git a/src/services/QuestionnaireReminderService.ts b/src/services/QuestionnaireReminderService.ts index 150055f..21e161b 100644 --- a/src/services/QuestionnaireReminderService.ts +++ b/src/services/QuestionnaireReminderService.ts @@ -6,6 +6,7 @@ import { safeAuditLogCreate } from './applicationAuditLog.service.js'; import { pickApplicationAuditContext } from './applicationAuditLog.service.js'; import logger from '../common/utils/logger.js'; import { getQuestionnaireReminderSettings } from './questionnaireReminderSettings.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; const TEMPLATE = 'QUESTIONNAIRE_REMINDER'; @@ -67,8 +68,7 @@ export class QuestionnaireReminderService { } static async sendReminderForApplication(application: any, source: 'scheduled' | 'manual' = 'scheduled') { - const base = process.env.FRONTEND_URL || 'http://localhost:5173'; - const link = `${base}/questionnaire/${application.applicationId}`; + const link = `${getFrontendBaseUrl()}/questionnaire/${application.applicationId}`; await NotificationService.sendQuestionnaireReminder( application.email, diff --git a/src/services/RelocationWorkflowService.ts b/src/services/RelocationWorkflowService.ts index de23280..dd8a04a 100644 --- a/src/services/RelocationWorkflowService.ts +++ b/src/services/RelocationWorkflowService.ts @@ -6,6 +6,7 @@ import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote. import logger from '../common/utils/logger.js'; import { NotificationService } from './NotificationService.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export class RelocationWorkflowService { /** @@ -102,7 +103,7 @@ export class RelocationWorkflowService { 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 portalBase = getFrontendBaseUrl(); const stageLabel = request.currentStage || request.status || targetStatus; const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); diff --git a/src/services/ResignationWorkflowService.ts b/src/services/ResignationWorkflowService.ts index d21eabc..4625bb0 100644 --- a/src/services/ResignationWorkflowService.ts +++ b/src/services/ResignationWorkflowService.ts @@ -9,6 +9,7 @@ import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote. import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { NomenclatureService } from '../common/utils/nomenclature.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export class ResignationWorkflowService { @@ -96,7 +97,7 @@ export class ResignationWorkflowService { }); if (user) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); diff --git a/src/services/SLAService.ts b/src/services/SLAService.ts index 4c83dba..44a91f2 100644 --- a/src/services/SLAService.ts +++ b/src/services/SLAService.ts @@ -8,6 +8,7 @@ import { resolveRecipientsForRoles } from './slaGeographyResolver.js'; import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote.js'; import type { WorkflowActivityRequestType } from '../common/utils/workflowWorknote.js'; import { +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; claimSlaNotificationDispatch, SlaDispatchKeys, updateSlaDispatchRecipientCount, @@ -496,7 +497,7 @@ export class SLAService { } private static linkForEntity(entityType: string, entityId: string): string { - const base = process.env.FRONTEND_URL || 'http://localhost:5173'; + const base = getFrontendBaseUrl(); switch (entityType) { case 'application': return `${base}/applications/${entityId}`; diff --git a/src/services/SlaOperationsService.ts b/src/services/SlaOperationsService.ts index b031c84..999c2ac 100644 --- a/src/services/SlaOperationsService.ts +++ b/src/services/SlaOperationsService.ts @@ -4,6 +4,7 @@ import { Op } from 'sequelize'; import { slaConfigLookupNames } from '../common/config/slaStageCatalog.js'; import { computeSlaTrackView, formatSlaDuration, type SlaBucket } from '../common/utils/slaMetrics.js'; import { SlaStatusService } from './SlaStatusService.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export type { SlaBucket }; @@ -20,7 +21,7 @@ function moduleFromEntityType(entityType: string): string { } function linkForEntity(entityType: string, entityId: string): string { - const base = process.env.FRONTEND_URL || 'http://localhost:5173'; + const base = getFrontendBaseUrl(); switch (entityType) { case 'application': return `${base}/applications/${entityId}`; diff --git a/src/services/TerminationWorkflowService.ts b/src/services/TerminationWorkflowService.ts index c63a170..257ddcf 100644 --- a/src/services/TerminationWorkflowService.ts +++ b/src/services/TerminationWorkflowService.ts @@ -11,6 +11,7 @@ import { writeWorkflowActivityWorknote } from '../common/utils/workflowWorknote. import { getOffboardingAuditAction, formatOffboardingAction } from '../common/utils/offboardingWorkflow.utils.js'; import { ParticipantService } from './ParticipantService.js'; import { syncSlaOnStageTransition } from '../common/utils/slaWorkflowSync.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export class TerminationWorkflowService { /** @@ -103,7 +104,7 @@ export class TerminationWorkflowService { }); if (user) { - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); const dealerPortalLink = `${portalBase}/termination/${termination.id}`; const isScnIssued = targetStage === TERMINATION_STAGES.SCN_ISSUED; @@ -263,7 +264,7 @@ export class TerminationWorkflowService { attributes: ['id', 'email', 'fullName', 'mobileNumber'] }); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; + const portalBase = getFrontendBaseUrl(); for (const u of adminUsers) { const phone = u.mobileNumber || null; await NotificationService.notify(u.id, u.email, { diff --git a/src/services/WorkflowService.ts b/src/services/WorkflowService.ts index 1b7c57c..9e0b343 100644 --- a/src/services/WorkflowService.ts +++ b/src/services/WorkflowService.ts @@ -10,6 +10,7 @@ import { } from '../common/config/constants.js'; import { NotificationService } from './NotificationService.js'; import { pickApplicationAuditContext, safeAuditLogCreate } from './applicationAuditLog.service.js'; +import { getFrontendBaseUrl } from '../common/utils/frontendUrl.js'; export class WorkflowService { /** @@ -150,11 +151,13 @@ export class WorkflowService { if (targetStatus === 'LOA Issued') templateCode = 'LOA_ISSUED'; if (targetStatus === 'Dealer Code Generated') templateCode = 'DEALER_CODE_READY'; - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; - let ctaLabel = 'View application'; if (templateCode === 'LOI_ISSUED') ctaLabel = 'View LOI'; else if (templateCode === 'LOA_ISSUED') ctaLabel = 'View LOA'; + if (['Rejected', 'LOI Rejected', 'LOA Rejected', 'Disqualified'].includes(targetStatus)) { + templateCode = 'APPLICANT_REJECTED'; + ctaLabel = 'View application'; + } await NotificationService.notify(targetUserId, application.email, { title: `Onboarding Update: ${targetStatus}`, @@ -165,10 +168,12 @@ export class WorkflowService { status: targetStatus, applicantName: application.applicantName, applicationId: application.applicationId, + location: application.preferredLocation || application.city || '', + rejectionReason: reason || 'N/A', reason: reason || 'N/A', salesCode: application.dealerCode?.salesCode || 'N/A', serviceCode: application.dealerCode?.serviceCode || 'N/A', - link: `${portalBase}/applications/${application.id}`, + link: `${getFrontendBaseUrl()}/applications/${application.id}`, ctaLabel, phone: user?.mobileNumber || user?.phone || application?.mobileNumber || application?.phone || '' }, @@ -179,8 +184,6 @@ export class WorkflowService { // Stakeholder Notifications tasks.push((async () => { const { notifyStakeholdersOnTransition } = await import('../common/utils/workflow-email-notifications.js'); - const portalBase = process.env.FRONTEND_URL || 'http://localhost:5173'; - let actionUserFullName = 'System'; if (userId) { const actionUser = await User.findByPk(userId, { attributes: ['fullName'] }); @@ -198,7 +201,7 @@ export class WorkflowService { actionUserFullName, action: reason || `Transitioned to ${targetStatus}`, remarks: reason || 'N/A', - link: `${portalBase}/applications/${application.id}` + link: `${getFrontendBaseUrl()}/applications/${application.id}` } ); })().catch(e => console.error('[WorkflowService] stakeholder notify failed:', e)));