From 46c6ddcc8013f82eb7a4763a10bd15946f117ee2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 22 Oct 2026 22:59:33 +0200 Subject: [PATCH] Implemented notification strategy --- .../governance/InstructionsSection.tsx | 2 +- .../components/governance/RulesSection.tsx | 4 +- .../monitoring/ContractsManagement.tsx | 6 +- .../src/components/ui/RiskHeatmapMatrix.tsx | 2 +- .../src/components/users/CreateUserModal.tsx | 83 ++-- .../src/pages/GovernancePage.tsx | 30 +- .../src/pages/SettingsPage.tsx | 162 +++++-- .../src/services/api.ts | 142 +++++- .../src/services/notificationRulesService.ts | 416 ++++++++++++++++++ .../src/services/roleClaimsCache.ts | 4 +- PostFundManagement.ConsoleUI/vite.config.ts | 4 +- 11 files changed, 784 insertions(+), 71 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/services/notificationRulesService.ts diff --git a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx index 67d686e..044b957 100644 --- a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx +++ b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx @@ -48,7 +48,7 @@ const instrumentSchema = z.object({ name: z.string().min(3, 'Funding instrument title must be at least 3 characters'), code: z.string().optional(), description: z.string().min(5, 'Description is required (minimum 5 characters)'), - status: z.coerce.number().default(ActivityStatus.Active), + status: z.coerce.number(), }); type InstrumentFormValues = z.infer; diff --git a/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx b/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx index 5c9faf8..5d799d4 100644 --- a/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx +++ b/PostFundManagement.ConsoleUI/src/components/governance/RulesSection.tsx @@ -46,8 +46,8 @@ const ruleSchema = z.object({ programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'), name: z.string().min(3, 'Rule name must be at least 3 characters'), description: z.string().min(5, 'Description is required (minimum 5 characters)'), - version: z.coerce.number().min(1, 'Version must be at least 1').default(1), - status: z.coerce.number().default(ActivityStatus.Active), + version: z.coerce.number().min(1, 'Version must be at least 1'), + status: z.coerce.number(), }); type RuleFormValues = z.infer; diff --git a/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx b/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx index 2c8fac7..f9d299d 100644 --- a/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx +++ b/PostFundManagement.ConsoleUI/src/components/monitoring/ContractsManagement.tsx @@ -898,7 +898,7 @@ export const ContractsManagement: React.FC = ({ className="space-y-4" > {/* ProblemDetails Error Alert */} - {createApiError && ( + {Boolean(createApiError) && ( setCreateApiError(null)} @@ -1126,7 +1126,7 @@ export const ContractsManagement: React.FC = ({ }} className="space-y-4" > - {uploadApiError && ( + {Boolean(uploadApiError) && ( setUploadApiError(null)} @@ -1228,7 +1228,7 @@ export const ContractsManagement: React.FC = ({ }} className="space-y-4" > - {updateApiError && ( + {Boolean(updateApiError) && ( setUpdateApiError(null)} diff --git a/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx b/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx index c276fd5..502b1c6 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/RiskHeatmapMatrix.tsx @@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC = ({ const projectMap = useMemo(() => { const map: Record = {}; projects.forEach((p) => { - map[String(p.id)] = p.name; + map[String(p.id)] = p.name || ''; }); return map; }, [projects]); diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx index 31a5c45..90a97e8 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -6,10 +6,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react'; import { Modal } from '../ui/Modal'; import { useToast } from '../ui/Toast'; -import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api'; +import { + createUser, + getSystemRoles, + assignUserRole, + getTenants, + getUsers, + notifyNewUserCreated, +} from '../../services/api'; import { useAuth } from '../../auth/AuthProvider'; import { useRbac } from '../../auth/rbac'; -import { CreateUserRequest, Role, Tenant } from '../../types'; +import { CreateUserRequest, Role, Tenant, User } from '../../types'; const createUserSchema = z.object({ tenantId: z.string().min(1, 'Tenant is required'), @@ -22,8 +29,8 @@ const createUserSchema = z.object({ userName: z.string().optional(), phoneNumber: z.string().optional(), initialRoleId: z.string().optional(), - lockoutEnabled: z.boolean().default(false), - twoFactorEnabled: z.boolean().default(false), + lockoutEnabled: z.boolean(), + twoFactorEnabled: z.boolean(), }); type CreateUserFormValues = z.infer; @@ -98,7 +105,9 @@ export const CreateUserModal: React.FC = ({ // Filter out Admin role if user has access to tenant self-help (Requirement 1) const assignableRoles = React.useMemo(() => { return systemRoles.filter((r) => { - const isRoleAdmin = r.name.toLowerCase() === 'admin' || r.id === '1'; + const rName = r.name || r.Name || ''; + const rId = r.id || r.Id || ''; + const isRoleAdmin = rName.toLowerCase() === 'admin' || rId === '1'; if (isRoleAdmin && hasTenantSelfHelpAccess) { return false; } @@ -117,8 +126,10 @@ export const CreateUserModal: React.FC = ({ mutationFn: async (values: CreateUserFormValues) => { // Security enforcement: Users with tenant self-help access cannot create admin users if (values.initialRoleId && hasTenantSelfHelpAccess) { - const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId); - if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) { + const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId); + const selName = selectedRole?.name || selectedRole?.Name || ''; + const selId = selectedRole?.id || selectedRole?.Id || ''; + if (selectedRole && (selName.toLowerCase() === 'admin' || selId === '1')) { throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.'); } } @@ -141,22 +152,40 @@ export const CreateUserModal: React.FC = ({ await createUser(values.tenantId, values.idNumber.trim(), payload); - if (values.initialRoleId) { - try { - await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); - await queryClient.invalidateQueries({ queryKey: ['users'] }); - const freshUsers = await getUsers(1, 1000, { email: values.email.trim() }); - const createdUser = freshUsers.find( - (u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase() - ); - if (createdUser) { - await assignUserRole(createdUser.id, values.initialRoleId); - } - } catch { - // Graceful fallback + let createdUser: User | undefined; + try { + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 1000, { email: values.email.trim() }); + createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase() + ); + if (createdUser && values.initialRoleId) { + await assignUserRole(createdUser.id, values.initialRoleId); } + } catch { + // Graceful fallback } + // RULE 3: Issue InApp and Email message directing new user to appropriate portal + const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId); + const roleName = selectedRole?.name || selectedRole?.Name || ''; + const isPartner = + roleName.toLowerCase().trim() === 'partner' || + String(values.initialRoleId || '').toLowerCase().trim() === 'partner'; + + const targetUserId = createdUser?.id || values.idNumber.trim(); + await notifyNewUserCreated({ + userId: targetUserId, + email: rawEmail, + isPartner, + roleName, + tenantId: values.tenantId, + }).catch(console.warn); + + queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + return values; }, onSuccess: (values) => { @@ -286,11 +315,15 @@ export const CreateUserModal: React.FC = ({ className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-white focus:outline-none focus:border-blue-500 disabled:opacity-50" > - {assignableRoles.map((r) => ( - - ))} + {assignableRoles.map((r) => { + const roleId = r.id || r.Id || ''; + const roleLabel = r.name || r.Name || ''; + return ( + + ); + })} {hasTenantSelfHelpAccess && (

diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index 4c9b92c..dc29557 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -60,6 +60,7 @@ import { assignUserRole, getSystemRoles, getCurrentUserTenantId, + notifyNewUserCreated, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; @@ -251,7 +252,7 @@ export const GovernancePage: React.FC = () => { const assignableRolesForOrg = React.useMemo(() => { return systemRoles.filter( - (r) => r.name.toLowerCase() !== 'admin' && r.id !== '1' + (r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1' ); }, [systemRoles]); @@ -274,9 +275,9 @@ export const GovernancePage: React.FC = () => { React.useEffect(() => { if (!quickUserRoleId && assignableRolesForOrg.length > 0) { - const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive'); - if (execRole) setQuickUserRoleId(execRole.id); - else setQuickUserRoleId(assignableRolesForOrg[0].id); + const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive'); + if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || ''); + else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || ''); } }, [assignableRolesForOrg, quickUserRoleId]); @@ -334,6 +335,19 @@ export const GovernancePage: React.FC = () => { orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); } + // RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console) + if (createdUser) { + await notifyNewUserCreated({ + userId: createdUser.id, + email: quickUserEmail, + isPartner: false, + roleName: 'Executive', + tenantId, + }).catch(console.warn); + queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + } + success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`); setQuickUserEmail(''); setQuickUserName(''); @@ -644,7 +658,7 @@ export const GovernancePage: React.FC = () => { programmeId: editingProgramme.id, name: data.name, portfolioId: Number(data.portfolioId), - status: data.status !== undefined ? Number(data.status) : undefined, + status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus, ownerId: resolvedOwnerId, }); } @@ -861,7 +875,7 @@ export const GovernancePage: React.FC = () => { key: 'portfolioId', format: (val) => { const port = portfolios.find((p) => String(p.id) === String(val)); - return port ? port.name : `Portfolio #${val}`; + return port && port.name ? port.name : `Portfolio #${val}`; }, }, { header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label }, @@ -1253,8 +1267,8 @@ export const GovernancePage: React.FC = () => {

- - {org ? org.name : `Org #${p.organisationId}`} + + {org && org.name ? org.name : `Org #${p.organisationId}`} {formatDate(p.createdAt)}
diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 476e16b..0429df2 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -59,7 +59,13 @@ import { getContracts, getAuditLogs, getNotifications, + notifyInviteApproved, + getCurrentUserTenantId, } from '../services/api'; +import { + getApprovedInviteIds, + markInviteAsApprovedInStorage, +} from '../services/notificationRulesService'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { useToast } from '../components/ui/Toast'; @@ -238,6 +244,31 @@ export const SettingsPage: React.FC = () => { const [inviteRecipientUuid, setInviteRecipientUuid] = useState(''); const [inviteMessage, setInviteMessage] = useState(''); const [invitePlatform, setInvitePlatform] = useState(NotificationPlatform.Email); + const [inviteAutoApprove, setInviteAutoApprove] = useState(true); + + // Approved invites tracking state + const currentTenantId = getCurrentUserTenantId(); + const [approvedInviteIds, setApprovedInviteIds] = useState(() => + getApprovedInviteIds(currentTenantId) + ); + + const handleApproveInvite = async (inv: Invite, idx: number) => { + const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`; + markInviteAsApprovedInStorage(inviteKey, currentTenantId); + setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey])); + + await notifyInviteApproved({ + invite: inv, + }).catch(console.warn); + + queryClient.invalidateQueries({ queryKey: ['invites'] }); + queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + success( + 'Invitation Approved', + 'Partner user has been notified via In-App and Email messages with link to Partner Portal (https://partner.ifri.khongisa.co.za/).' + ); + }; // Invites filtering & search const [inviteSearchTerm, setInviteSearchTerm] = useState(''); @@ -282,12 +313,33 @@ export const SettingsPage: React.FC = () => { const sendInviteMutation = useMutation({ mutationFn: (payload: InviteRequest) => sendInvite(payload), - onSuccess: () => { + onSuccess: (data, variables) => { success('Invitation Dispatched', 'Statutory communications invitation sent successfully.'); queryClient.invalidateQueries({ queryKey: ['invites'] }); setIsSendInviteModalOpen(false); setInviteRecipientUuid(''); setInviteMessage(''); + + if (inviteAutoApprove) { + const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`; + markInviteAsApprovedInStorage(invId, currentTenantId); + setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId])); + + notifyInviteApproved({ + invite: { + id: invId, + recipient: variables.recipient, + invitedOrganisationId: variables.invitedOrganisationId, + organisationId: variables.organisationId, + awardId: variables.awardId, + contractId: variables.contractId, + message: variables.message, + }, + }).then(() => { + queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }).catch(console.warn); + } }, onError: (err: unknown) => { toastError('Dispatch Error', err); @@ -1125,7 +1177,7 @@ export const SettingsPage: React.FC = () => {
- +
@@ -1140,46 +1192,90 @@ export const SettingsPage: React.FC = () => { Target Org / Grant + Created Date + {loadingInvites ? ( - ) : sortedInvites.length === 0 ? ( - ) : ( - sortedInvites.map((inv, idx) => ( - - - - - - - - )) + sortedInvites.map((inv, idx) => { + const isApproved = + approvedInviteIds.includes(String(inv.id)) || + approvedInviteIds.includes(`inv_${idx}`) || + approvedInviteIds.includes(String(idx)); + + return ( + + + + + + + + + + ); + }) )}
+ Status + + Actions +
+ Loading invitations ledger...
+ No invitations recorded yet. Use “Dispatch Invitation” to send one.
- {inv.recipient} - - - {getNotificationPlatformLabel(inv.platform)} - - - {inv.message} - - Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId} - - {inv.createdAt ? formatDate(inv.createdAt) : 'Recently'} -
+ {inv.recipient} + + + {getNotificationPlatformLabel(inv.platform)} + + + {inv.message} + + Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId} + + {isApproved ? ( + + + Approved + + ) : ( + + + Pending Approval + + )} + + {inv.createdAt ? formatDate(inv.createdAt) : 'Recently'} + + {isApproved ? ( + + + Partner Notified + + ) : ( + + )} +
@@ -2559,6 +2655,22 @@ export const SettingsPage: React.FC = () => { />
+
+ setInviteAutoApprove(e.target.checked)} + className="mt-0.5 w-4 h-4 rounded text-blue-600 bg-slate-900 border-slate-700 focus:ring-blue-500 cursor-pointer" + /> + +
+