Implemented notification strategy

This commit is contained in:
2026-10-22 22:59:33 +02:00
parent 7071c404f0
commit 46c6ddcc80
11 changed files with 784 additions and 71 deletions
@@ -1,6 +1,19 @@
import axios, { AxiosError } from 'axios';
import { apiClient } from './httpClient';
import { toolsService } from './toolsService';
import {
notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved,
notifyNewUserCreated,
isApprovedOrActiveStatus,
} from './notificationRulesService';
export {
notifyTenantManagersAndExecutivesOnRecordStatusChange,
notifyInviteApproved,
notifyNewUserCreated,
isApprovedOrActiveStatus,
};
import {
getCurrentUserSub,
getCurrentUserTenantId,
@@ -64,6 +77,7 @@ import {
AuditLog,
ProblemDetails,
ActivityStatus,
ApprovalStatus,
Tenant,
TenantOrganisation,
TenantUser,
@@ -239,6 +253,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
};
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active;
const res = await apiClient.put('/v1/api/governance/organisations', {
organisationId: toIntegerId(payload.organisationId),
name: payload.name,
@@ -247,8 +262,16 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
idNumber: payload.idNumber,
organisationUser: payload.organisationUser,
type: Number(payload.type),
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
status: orgStatus,
});
if (isApprovedOrActiveStatus(orgStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Organisation',
recordId: payload.organisationId,
newStatus: orgStatus,
title: payload.name,
}).catch(console.warn);
}
return res.data;
};
@@ -622,6 +645,14 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
if (existing) {
saveStoredInstruction(updatedInstrument);
}
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Funding Instrument',
recordId: targetId!,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
return updatedInstrument;
};
@@ -667,12 +698,21 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
}
const progStatus = Number(payload.status) || 1;
const res = await apiClient.put('/v1/api/projects/programme', {
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
status: Number(payload.status) || 1,
status: progStatus,
});
if (isApprovedOrActiveStatus(progStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Programme',
recordId: payload.programmeId,
newStatus: progStatus,
title: payload.name,
}).catch(console.warn);
}
return res.data;
};
@@ -725,6 +765,13 @@ export const updateProjectStatus = async (payload: UpdateProjectStatusRequest):
projectId: toIntegerId(payload.projectId),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Project',
recordId: payload.projectId,
newStatus: payload.status,
}).catch(console.warn);
}
};
export const getActivities = async (projectId: string | number): Promise<Activity[]> => {
@@ -826,6 +873,14 @@ export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<
priority: Number(payload.priority) || 1,
status: Number(payload.status) || 1,
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Milestone',
recordId: payload.milestoneId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
};
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
@@ -884,6 +939,13 @@ export const updateAward = async (payload: UpdateAwardRequest): Promise<void> =>
amount: Number(payload.amount),
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
});
if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Award',
recordId: payload.awardId,
newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved,
}).catch(console.warn);
}
};
export const fetchHashId = async (id64: number | string): Promise<string> => {
@@ -1018,6 +1080,14 @@ export const updateContract = async (payload: UpdateContractRequest): Promise<vo
contractId: toIntegerId(payload.contractId),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Contract',
recordId: payload.contractId,
newStatus: payload.status,
}).catch(console.warn);
}
};
export const uploadContractFile = async (
@@ -1078,6 +1148,13 @@ export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void>
budgetId: toIntegerId(payload.budgetId),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Budget',
recordId: payload.budgetId,
newStatus: payload.status,
}).catch(console.warn);
}
};
// ==========================================
@@ -1116,6 +1193,11 @@ export const updateDisbursement = async (payload: UpdateDisbursementRequest): Pr
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
const hashedDisbId = await toolsService.encodeId(disbursementId);
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Disbursement',
recordId: disbursementId,
newStatus: ApprovalStatus.Approved,
}).catch(console.warn);
};
/**
@@ -1316,6 +1398,13 @@ export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<vo
evidenceType: Number(payload.evidenceType),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Evidence',
recordId: payload.evidenceId,
newStatus: payload.status,
}).catch(console.warn);
}
};
// ==========================================
@@ -1689,6 +1778,14 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
name: payload.name,
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Tenant',
recordId: payload.tenantId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
};
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
@@ -1854,6 +1951,14 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
description: payload.description,
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Outcome',
recordId: payload.outcomeId,
newStatus: payload.status,
title: payload.name,
}).catch(console.warn);
}
};
// ==========================================
@@ -1915,6 +2020,14 @@ export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise
revisedBudget: Number(payload.revisedBudget),
status: Number(payload.status),
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Change Request',
recordId: payload.changeRequestId,
newStatus: payload.status,
title: payload.title,
}).catch(console.warn);
}
};
// ==========================================
@@ -1975,6 +2088,14 @@ export const updateComplianceItem = async (payload: UpdateComplianceItemRequest)
status: Number(payload.status),
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Compliance Item',
recordId: payload.complianceItemId,
newStatus: payload.status,
title: payload.title,
}).catch(console.warn);
}
};
// ==========================================
@@ -2042,6 +2163,15 @@ export const updateIssue = async (payload: UpdateIssueRequest): Promise<void> =>
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
assignedTo: payload.assignedTo,
});
const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active;
if (isApprovedOrActiveStatus(issueStatus)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Issue',
recordId: payload.issueId,
newStatus: issueStatus,
title: payload.title,
}).catch(console.warn);
}
};
// ==========================================
@@ -2106,6 +2236,14 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
status: Number(payload.status),
visitedAt: visitedAtIso,
});
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Site Visit',
recordId: payload.siteVisitId,
newStatus: payload.status,
title: payload.inspectorName,
}).catch(console.warn);
}
};
const LOCAL_RULES_KEY = 'pfm_local_rules';
@@ -0,0 +1,416 @@
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<void> {
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<User[]> {
try {
const res = await apiClient.post<User[]>(`/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<User[]> {
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<void> {
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<Invite> & {
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<void> {
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<void> {
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 {}
}
@@ -520,7 +520,7 @@ export async function resolveUserRolesAndClaims(
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
const roles = await fetchAndCacheSystemRoles();
await Promise.allSettled(
roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
roles.map((role) => fetchAndCacheRoleClaims(role.id || role.Id || '', role.name || role.Name || ''))
);
}
@@ -563,7 +563,7 @@ export async function getSystemClaims(): Promise<Claim[]> {
try {
const roles = await fetchAndCacheSystemRoles();
for (const r of roles) {
const claims = await fetchAndCacheRoleClaims(r.id, r.name);
const claims = await fetchAndCacheRoleClaims(r.id || r.Id || '', r.name || r.Name || '');
claims.forEach((c) => {
const norm = normalizeClaim(c);
if (norm.claimType && !claimsMap.has(norm.claimType)) {