import { NotificationPlatform, NotificationStatus, ApprovalStatus, ActivityStatus, ContractStatus, ComplianceStatus, User, Invite, CreateNotificationRequest, } from '../types'; import { apiClient } from './httpClient'; import { getCurrentUserSub, getCurrentUserClaims, getCurrentUserTenantId, } from '../auth/AuthProvider'; export const CONSOLE_PORTAL_URL = 'https://console.ifri.khongisa.co.za/'; export const PARTNER_PORTAL_URL = 'https://partner.ifri.khongisa.co.za/'; /** * Dispatches a notification request directly via apiClient */ export async function dispatchNotification(payload: CreateNotificationRequest): Promise { const tenantId = getCurrentUserTenantId(); const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null ? Number(payload.organisationId) : (tenantId !== null ? tenantId : 1); await apiClient.post('/v1/api/communications/notifications', { organisationId: orgId, recipient: payload.recipient, platform: payload.platform, subject: payload.subject || null, destination: payload.destination || 'In-App User Inbox', message: payload.message, status: payload.status !== undefined ? payload.status : undefined, }); } /** * Retrieves users from directory directly via apiClient */ export async function fetchDirectoryUsers(page = 1, pageSize = 200): Promise { try { const res = await apiClient.post(`/v1/api/users/${page}/${pageSize}`, {}); return res.data || []; } catch (err) { console.warn('[NotificationRules] Failed to fetch users directory:', err); return []; } } /** * Validates whether a given status corresponds to "Approved" or "Active". */ export function isApprovedOrActiveStatus(status: number | string | undefined | null): boolean { if (status === undefined || status === null) return false; // Numeric enum matching const numStatus = Number(status); if ( numStatus === ApprovalStatus.Approved || // 3 numStatus === ActivityStatus.Active || // 1 numStatus === ContractStatus.Active || // 3 numStatus === ContractStatus.Executed || // 3 numStatus === ComplianceStatus.Met || // 2 numStatus === ComplianceStatus.Exceeded // 3 ) { return true; } // String matching const strStatus = String(status).toLowerCase().trim(); return ( strStatus === 'approved' || strStatus === 'active' || strStatus === 'executed' || strStatus === 'met' || strStatus === 'exceeded' || strStatus === 'verified' ); } /** * Formats a friendly label for the approved or active status. */ export function getApprovedOrActiveStatusLabel(status: number | string | undefined | null): string { if (status === undefined || status === null) return 'Active'; const numStatus = Number(status); if (numStatus === ApprovalStatus.Approved) return 'Approved'; if (numStatus === ActivityStatus.Active) return 'Active'; if (numStatus === ContractStatus.Active) return 'Active'; if (numStatus === ComplianceStatus.Met) return 'Compliant (Met)'; const str = String(status); return str.charAt(0).toUpperCase() + str.slice(1); } /** * Retrieves Tenant Managers and Executives within the active tenant context. * Falls back to the currently logged in user if directory lookup is restricted or empty. */ export async function getTenantManagersAndExecutives(): Promise { try { const tenantId = getCurrentUserTenantId(); const users = await fetchDirectoryUsers(1, 200); const managersAndExecutives = users.filter((u) => { const roleStr = String(u.role || '').toLowerCase(); const isManagerOrExec = roleStr.includes('manager') || roleStr.includes('executive') || roleStr.includes('director') || roleStr === '2' || // Role ID 2 = Executive roleStr === '4'; // Role ID 4 = Manager const matchesTenant = !tenantId || !u.tenant_id || Number(u.tenant_id) === Number(tenantId); return isManagerOrExec && matchesTenant; }); if (managersAndExecutives.length > 0) { return managersAndExecutives; } // Secondary fallback: Any user with admin or supervisor if no direct manager/executive found const elevatedUsers = users.filter((u) => { const roleStr = String(u.role || '').toLowerCase(); return ( roleStr.includes('admin') || roleStr.includes('supervisor') || roleStr === '1' || roleStr === '3' ); }); if (elevatedUsers.length > 0) { return elevatedUsers; } } catch (err) { console.warn('[NotificationRules] Directory lookup failed, using authenticated identity fallback:', err); } // Graceful fallback to authenticated user identity so telemetry notification is recorded const currentSub = getCurrentUserSub(); const currentClaims = getCurrentUserClaims(); if (currentSub) { return [ { id: currentSub, email: (currentClaims.email as string) || (currentClaims.preferred_username as string) || 'manager@pfm.org.za', status: ActivityStatus.Active, lastLoginAt: null, role: 'Manager', name: (currentClaims.name as string) || 'Tenant Manager', }, ]; } return []; } export interface RecordStatusChangeNotificationParams { recordType: string; recordId: string | number; newStatus: number | string; title?: string; previousStatus?: number | string; } /** * RULE 1: * "Whenever a record change status to Approved, Active (for contract or similar records) exclusding users; * the system must issue an InApp and Email message to Tenant Managers and Executives." */ export async function notifyTenantManagersAndExecutivesOnRecordStatusChange( params: RecordStatusChangeNotificationParams ): Promise { const { recordType, recordId, newStatus, title } = params; // Rule constraint: Strictly exclude users if (recordType.toLowerCase() === 'user' || recordType.toLowerCase() === 'users') { return; } // Only trigger when the target status is Approved or Active if (!isApprovedOrActiveStatus(newStatus)) { return; } const statusLabel = getApprovedOrActiveStatusLabel(newStatus); const orgId = getCurrentUserTenantId() || 1; const managersAndExecutives = await getTenantManagersAndExecutives(); if (managersAndExecutives.length === 0) { return; } const recordDescriptor = title ? `${recordType} "${title}" (#${recordId})` : `${recordType} #${recordId}`; const inAppSubject = `[${statusLabel}] ${recordType} #${recordId} is now ${statusLabel}`; const inAppMessage = `### Statutory Record Update\n\nThe record **${recordDescriptor}** has officially transitioned to **${statusLabel}** status.\n\n- **Record Category:** ${recordType}\n- **Identifier:** #${recordId}\n- **Updated Status:** ${statusLabel}\n- **Timestamp:** ${new Date().toLocaleString()}\n- **Portal Reference:** [Open PFM Console](${CONSOLE_PORTAL_URL})\n\nPlease review the updated portfolio commitments accordingly.`; const emailSubject = `[PFM Governance Alert] ${recordType} #${recordId} Status Transition: ${statusLabel}`; const emailMessage = `### Formal Statutory Notification\n\nThis is an automated notification to Tenant Managers and Executives regarding an approved record status update.\n\n**Record Details:**\n- **Category:** ${recordType}\n- **Reference Identifier:** #${recordId}\n${title ? `- **Title:** ${title}\n` : ''}- **New Lifecycle Status:** **${statusLabel}**\n- **Effective Timestamp:** ${new Date().toUTCString()}\n\nYou can inspect this record in the PFM Management Console: [Access Management Console](${CONSOLE_PORTAL_URL}).`; // Dispatch both InApp and Email notifications to each Tenant Manager and Executive for (const recipient of managersAndExecutives) { if (!recipient.id) continue; const recipientEmail = recipient.email || 'manager@pfm.org.za'; // 1. In-App Notification try { const inAppReq: CreateNotificationRequest = { organisationId: orgId, recipient: recipient.id, platform: NotificationPlatform.InApp, subject: inAppSubject, destination: recipientEmail, message: inAppMessage, status: NotificationStatus.Delivered, }; await dispatchNotification(inAppReq); } catch (err) { console.warn(`[NotificationRules] Failed to send InApp message to ${recipient.id}:`, err); } // 2. Email Notification try { const emailReq: CreateNotificationRequest = { organisationId: orgId, recipient: recipient.id, platform: NotificationPlatform.Email, subject: emailSubject, destination: `To: ${recipientEmail} | From: PFM Automated Governance System`, message: emailMessage, status: NotificationStatus.Queued, }; await dispatchNotification(emailReq); } catch (err) { console.warn(`[NotificationRules] Failed to send Email message to ${recipientEmail}:`, err); } } } export interface InviteApprovedNotificationParams { invite: Partial & { id: number | string; recipient: string; invitedOrganisationId?: number | string; organisationId?: number | string; awardId?: number | string; contractId?: number | string; message?: string; }; partnerUser?: User | null; partnerEmail?: string; } /** * RULE 2: * "When an Invite is Approved, the Partner user must be notified via Email and InApp message as well." */ export async function notifyInviteApproved( params: InviteApprovedNotificationParams ): Promise { const { invite, partnerUser, partnerEmail } = params; if (!invite.recipient) return; const orgId = invite.invitedOrganisationId || invite.organisationId || getCurrentUserTenantId() || 1; const targetEmail = partnerEmail || partnerUser?.email || invite.recipient; const inAppSubject = 'Onboarding Invitation Approved: Access to Partner Portal Granted'; const inAppMessage = `### Statutory Invitation Approved\n\nYour statutory onboarding invitation for **Organisation #${invite.invitedOrganisationId || invite.organisationId}** (Funding Award #${invite.awardId || 'N/A'}, Contract #${invite.contractId || 'N/A'}) has been formally **Approved**.\n\nYou may now access the **PFM Partner Portal** to review contractual agreements, complete KYC compliance, and submit milestone deliverables:\n\nšŸ‘‰ [Open Partner Portal](${PARTNER_PORTAL_URL})`; const emailSubject = '[PFM Partner Portal] Statutory Invitation Approved - Welcome Onboard'; const emailMessage = `### Formal Statutory Onboarding Approval\n\nDear Grantee / Project Partner,\n\nWe are pleased to inform you that your statutory onboarding invitation has been formally **Approved** by the funding authority.\n\n**Invitation Summary:**\n- **Recipient Key:** \`${invite.recipient}\`\n- **Target Organisation:** #${invite.invitedOrganisationId || invite.organisationId}\n- **Funding Award ID:** #${invite.awardId || 'N/A'}\n- **Contract Reference:** #${invite.contractId || 'N/A'}\n- **Authorisation Status:** **Approved & Active**\n\n**Next Steps:**\nPlease log in to the PFM Partner Portal at: **${PARTNER_PORTAL_URL}**\n\nOnce logged in, please complete your statutory recipient onboarding, verify bank details, and review milestone schedules.`; // 1. In-App Notification to Partner try { const inAppReq: CreateNotificationRequest = { organisationId: orgId, recipient: invite.recipient, platform: NotificationPlatform.InApp, subject: inAppSubject, destination: targetEmail, message: inAppMessage, status: NotificationStatus.Delivered, }; await dispatchNotification(inAppReq); } catch (err) { console.warn('[NotificationRules] Failed to issue InApp invite approval notification:', err); } // 2. Email Notification to Partner try { const emailReq: CreateNotificationRequest = { organisationId: orgId, recipient: invite.recipient, platform: NotificationPlatform.Email, subject: emailSubject, destination: `To: ${targetEmail} | From: PFM Statutory Communications`, message: emailMessage, status: NotificationStatus.Queued, }; await dispatchNotification(emailReq); } catch (err) { console.warn('[NotificationRules] Failed to issue Email invite approval notification:', err); } } export interface NewUserCreatedNotificationParams { userId: string; email: string; isPartner: boolean; roleName?: string; tenantId?: number | string | null; } /** * RULE 3: * "When a new user is created, an InApp and Email message must be issued to them notifying them of this * and directing them to login (https://console.ifri.khongisa.co.za/), * PartnerUsers (users with Partner role) should be instructed to navigate to the partner portal (https://partner.ifri.khongisa.co.za/)" */ export async function notifyNewUserCreated( params: NewUserCreatedNotificationParams ): Promise { const { userId, email, isPartner, roleName, tenantId } = params; if (!userId || !email) return; const orgId = tenantId ? Number(tenantId) : (getCurrentUserTenantId() || 1); const targetUrl = isPartner ? PARTNER_PORTAL_URL : CONSOLE_PORTAL_URL; const targetPortalName = isPartner ? 'PFM Partner Portal' : 'PFM Management Console'; const roleDisplay = roleName || (isPartner ? 'Partner' : 'Console User'); const inAppSubject = `Welcome to PFM - Your Account Has Been Created`; const inAppMessage = isPartner ? `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nAs a designated project partner, please navigate to the **Partner Portal** to complete statutory recipient onboarding, verify banking details, and collaborate on project deliverables:\n\nšŸ‘‰ [Open Partner Portal](${PARTNER_PORTAL_URL})\n\n**Registered Email:** \`${email}\`` : `### Welcome to the Post-Fund Management (PFM) Platform\n\nYour user account has been successfully provisioned with the **${roleDisplay}** role.\n\nPlease log in to the **Management Console** using your registered corporate credentials to access your administrative dashboard and governance responsibilities:\n\nšŸ‘‰ [Open Management Console](${CONSOLE_PORTAL_URL})\n\n**Registered Email:** \`${email}\``; const emailSubject = `Welcome to PFM - Account Provisioned & Login Instructions`; const emailMessage = isPartner ? `### Welcome to the PFM Platform - Partner Account Activation\n\nDear Partner,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **Partner** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Dedicated Portal URL:** **${PARTNER_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Partner Portal: [${PARTNER_PORTAL_URL}](${PARTNER_PORTAL_URL})\n2. Sign in using your registered email address.\n3. Complete your organization's statutory onboarding checklist and review assigned grant awards.` : `### Welcome to the PFM Platform - User Account Provisioned\n\nDear Colleague,\n\nYour account on the Post-Fund Management (PFM) Platform has been successfully created with the **${roleDisplay}** role.\n\n**Your Access Details:**\n- **Registered Email:** ${email}\n- **Assigned Role:** ${roleDisplay}\n- **Management Console URL:** **${CONSOLE_PORTAL_URL}**\n\n**Instructions:**\n1. Navigate to the Management Console: [${CONSOLE_PORTAL_URL}](${CONSOLE_PORTAL_URL})\n2. Sign in using your registered credentials.\n3. Access your assigned programmes, portfolios, and operational tools.`; // 1. In-App Message try { const inAppReq: CreateNotificationRequest = { organisationId: orgId, recipient: userId, platform: NotificationPlatform.InApp, subject: inAppSubject, destination: email, message: inAppMessage, status: NotificationStatus.Delivered, }; await dispatchNotification(inAppReq); } catch (err) { console.warn('[NotificationRules] Failed to send InApp message for new user:', err); } // 2. Email Message try { const emailReq: CreateNotificationRequest = { organisationId: orgId, recipient: userId, platform: NotificationPlatform.Email, subject: emailSubject, destination: `To: ${email} | From: PFM Security & Identity Administration`, message: emailMessage, status: NotificationStatus.Queued, }; await dispatchNotification(emailReq); } catch (err) { console.warn('[NotificationRules] Failed to send Email message for new user:', err); } } /** * Local storage persistence helper for approved invitations ledger state. */ const APPROVED_INVITES_STORAGE_KEY_PREFIX = 'pfm_approved_invites_'; export function getApprovedInviteIds(tenantId?: number | string | null): string[] { if (typeof window === 'undefined') return []; try { const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`; const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : []; } catch { return []; } } export function markInviteAsApprovedInStorage( inviteId: string | number, tenantId?: number | string | null ): void { if (typeof window === 'undefined') return; try { const key = `${APPROVED_INVITES_STORAGE_KEY_PREFIX}${tenantId || 'global'}`; const current = getApprovedInviteIds(tenantId); const strId = String(inviteId); if (!current.includes(strId)) { const updated = [...current, strId]; localStorage.setItem(key, JSON.stringify(updated)); } } catch {} }