Implemented notification strategy
This commit is contained in:
@@ -48,7 +48,7 @@ const instrumentSchema = z.object({
|
|||||||
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
|
name: z.string().min(3, 'Funding instrument title must be at least 3 characters'),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
description: z.string().min(5, 'Description is required (minimum 5 characters)'),
|
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<typeof instrumentSchema>;
|
type InstrumentFormValues = z.infer<typeof instrumentSchema>;
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ const ruleSchema = z.object({
|
|||||||
programmeId: z.coerce.number().min(1, 'Please select an associated programme from the dropdown'),
|
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'),
|
name: z.string().min(3, 'Rule name must be at least 3 characters'),
|
||||||
description: z.string().min(5, 'Description is required (minimum 5 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),
|
version: z.coerce.number().min(1, 'Version must be at least 1'),
|
||||||
status: z.coerce.number().default(ActivityStatus.Active),
|
status: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type RuleFormValues = z.infer<typeof ruleSchema>;
|
type RuleFormValues = z.infer<typeof ruleSchema>;
|
||||||
|
|||||||
@@ -898,7 +898,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{/* ProblemDetails Error Alert */}
|
{/* ProblemDetails Error Alert */}
|
||||||
{createApiError && (
|
{Boolean(createApiError) && (
|
||||||
<ProblemDetailsAlert
|
<ProblemDetailsAlert
|
||||||
error={createApiError}
|
error={createApiError}
|
||||||
onDismiss={() => setCreateApiError(null)}
|
onDismiss={() => setCreateApiError(null)}
|
||||||
@@ -1126,7 +1126,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{uploadApiError && (
|
{Boolean(uploadApiError) && (
|
||||||
<ProblemDetailsAlert
|
<ProblemDetailsAlert
|
||||||
error={uploadApiError}
|
error={uploadApiError}
|
||||||
onDismiss={() => setUploadApiError(null)}
|
onDismiss={() => setUploadApiError(null)}
|
||||||
@@ -1228,7 +1228,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
{updateApiError && (
|
{Boolean(updateApiError) && (
|
||||||
<ProblemDetailsAlert
|
<ProblemDetailsAlert
|
||||||
error={updateApiError}
|
error={updateApiError}
|
||||||
onDismiss={() => setUpdateApiError(null)}
|
onDismiss={() => setUpdateApiError(null)}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
|
|||||||
const projectMap = useMemo(() => {
|
const projectMap = useMemo(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
projects.forEach((p) => {
|
projects.forEach((p) => {
|
||||||
map[String(p.id)] = p.name;
|
map[String(p.id)] = p.name || '';
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|||||||
@@ -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 { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { useToast } from '../ui/Toast';
|
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 { useAuth } from '../../auth/AuthProvider';
|
||||||
import { useRbac } from '../../auth/rbac';
|
import { useRbac } from '../../auth/rbac';
|
||||||
import { CreateUserRequest, Role, Tenant } from '../../types';
|
import { CreateUserRequest, Role, Tenant, User } from '../../types';
|
||||||
|
|
||||||
const createUserSchema = z.object({
|
const createUserSchema = z.object({
|
||||||
tenantId: z.string().min(1, 'Tenant is required'),
|
tenantId: z.string().min(1, 'Tenant is required'),
|
||||||
@@ -22,8 +29,8 @@ const createUserSchema = z.object({
|
|||||||
userName: z.string().optional(),
|
userName: z.string().optional(),
|
||||||
phoneNumber: z.string().optional(),
|
phoneNumber: z.string().optional(),
|
||||||
initialRoleId: z.string().optional(),
|
initialRoleId: z.string().optional(),
|
||||||
lockoutEnabled: z.boolean().default(false),
|
lockoutEnabled: z.boolean(),
|
||||||
twoFactorEnabled: z.boolean().default(false),
|
twoFactorEnabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||||
@@ -98,7 +105,9 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
// Filter out Admin role if user has access to tenant self-help (Requirement 1)
|
||||||
const assignableRoles = React.useMemo(() => {
|
const assignableRoles = React.useMemo(() => {
|
||||||
return systemRoles.filter((r) => {
|
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) {
|
if (isRoleAdmin && hasTenantSelfHelpAccess) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -117,8 +126,10 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
mutationFn: async (values: CreateUserFormValues) => {
|
mutationFn: async (values: CreateUserFormValues) => {
|
||||||
// Security enforcement: Users with tenant self-help access cannot create admin users
|
// Security enforcement: Users with tenant self-help access cannot create admin users
|
||||||
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
if (values.initialRoleId && hasTenantSelfHelpAccess) {
|
||||||
const selectedRole = systemRoles.find((r) => r.id === values.initialRoleId);
|
const selectedRole = systemRoles.find((r) => (r.id || r.Id) === values.initialRoleId);
|
||||||
if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1')) {
|
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.');
|
throw new Error('Users who have access to tenant self-help are restricted from creating Admin users.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,21 +152,39 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
|
|
||||||
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
await createUser(values.tenantId, values.idNumber.trim(), payload);
|
||||||
|
|
||||||
if (values.initialRoleId) {
|
let createdUser: User | undefined;
|
||||||
try {
|
try {
|
||||||
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
|
||||||
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
await queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
const freshUsers = await getUsers(1, 1000, { email: values.email.trim() });
|
||||||
const createdUser = freshUsers.find(
|
createdUser = freshUsers.find(
|
||||||
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
(u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
if (createdUser) {
|
if (createdUser && values.initialRoleId) {
|
||||||
await assignUserRole(createdUser.id, values.initialRoleId);
|
await assignUserRole(createdUser.id, values.initialRoleId);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Graceful fallback
|
// 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;
|
return values;
|
||||||
},
|
},
|
||||||
@@ -286,11 +315,15 @@ export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<option value="">Select Initial Role (Optional)</option>
|
<option value="">Select Initial Role (Optional)</option>
|
||||||
{assignableRoles.map((r) => (
|
{assignableRoles.map((r) => {
|
||||||
<option key={r.id} value={r.id}>
|
const roleId = r.id || r.Id || '';
|
||||||
{r.name}
|
const roleLabel = r.name || r.Name || '';
|
||||||
|
return (
|
||||||
|
<option key={roleId} value={roleId}>
|
||||||
|
{roleLabel}
|
||||||
</option>
|
</option>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</select>
|
</select>
|
||||||
{hasTenantSelfHelpAccess && (
|
{hasTenantSelfHelpAccess && (
|
||||||
<p className="text-[10px] text-amber-400 mt-1">
|
<p className="text-[10px] text-amber-400 mt-1">
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
assignUserRole,
|
assignUserRole,
|
||||||
getSystemRoles,
|
getSystemRoles,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
|
notifyNewUserCreated,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
@@ -251,7 +252,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const assignableRolesForOrg = React.useMemo(() => {
|
const assignableRolesForOrg = React.useMemo(() => {
|
||||||
return systemRoles.filter(
|
return systemRoles.filter(
|
||||||
(r) => r.name.toLowerCase() !== 'admin' && r.id !== '1'
|
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
|
||||||
);
|
);
|
||||||
}, [systemRoles]);
|
}, [systemRoles]);
|
||||||
|
|
||||||
@@ -274,9 +275,9 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
|
||||||
const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive');
|
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
|
||||||
if (execRole) setQuickUserRoleId(execRole.id);
|
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
|
||||||
else setQuickUserRoleId(assignableRolesForOrg[0].id);
|
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
|
||||||
}
|
}
|
||||||
}, [assignableRolesForOrg, quickUserRoleId]);
|
}, [assignableRolesForOrg, quickUserRoleId]);
|
||||||
|
|
||||||
@@ -334,6 +335,19 @@ export const GovernancePage: React.FC = () => {
|
|||||||
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
|
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.`);
|
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
|
||||||
setQuickUserEmail('');
|
setQuickUserEmail('');
|
||||||
setQuickUserName('');
|
setQuickUserName('');
|
||||||
@@ -644,7 +658,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
programmeId: editingProgramme.id,
|
programmeId: editingProgramme.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
portfolioId: Number(data.portfolioId),
|
portfolioId: Number(data.portfolioId),
|
||||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
|
||||||
ownerId: resolvedOwnerId,
|
ownerId: resolvedOwnerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -861,7 +875,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
key: 'portfolioId',
|
key: 'portfolioId',
|
||||||
format: (val) => {
|
format: (val) => {
|
||||||
const port = portfolios.find((p) => String(p.id) === String(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 },
|
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
|
||||||
@@ -1253,8 +1267,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
|
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
|
||||||
{org ? org.name : `Org #${p.organisationId}`}
|
{org && org.name ? org.name : `Org #${p.organisationId}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,13 @@ import {
|
|||||||
getContracts,
|
getContracts,
|
||||||
getAuditLogs,
|
getAuditLogs,
|
||||||
getNotifications,
|
getNotifications,
|
||||||
|
notifyInviteApproved,
|
||||||
|
getCurrentUserTenantId,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
import {
|
||||||
|
getApprovedInviteIds,
|
||||||
|
markInviteAsApprovedInStorage,
|
||||||
|
} from '../services/notificationRulesService';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
@@ -238,6 +244,31 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
|
||||||
const [inviteMessage, setInviteMessage] = useState('');
|
const [inviteMessage, setInviteMessage] = useState('');
|
||||||
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
|
||||||
|
const [inviteAutoApprove, setInviteAutoApprove] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Approved invites tracking state
|
||||||
|
const currentTenantId = getCurrentUserTenantId();
|
||||||
|
const [approvedInviteIds, setApprovedInviteIds] = useState<string[]>(() =>
|
||||||
|
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
|
// Invites filtering & search
|
||||||
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
|
||||||
@@ -282,12 +313,33 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const sendInviteMutation = useMutation({
|
const sendInviteMutation = useMutation({
|
||||||
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
mutationFn: (payload: InviteRequest) => sendInvite(payload),
|
||||||
onSuccess: () => {
|
onSuccess: (data, variables) => {
|
||||||
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
|
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
|
||||||
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
queryClient.invalidateQueries({ queryKey: ['invites'] });
|
||||||
setIsSendInviteModalOpen(false);
|
setIsSendInviteModalOpen(false);
|
||||||
setInviteRecipientUuid('');
|
setInviteRecipientUuid('');
|
||||||
setInviteMessage('');
|
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) => {
|
onError: (err: unknown) => {
|
||||||
toastError('Dispatch Error', err);
|
toastError('Dispatch Error', err);
|
||||||
@@ -1125,7 +1177,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto min-w-0 w-full">
|
<div className="overflow-x-auto min-w-0 w-full">
|
||||||
<table className="w-full text-left text-xs border-collapse min-w-[850px]">
|
<table className="w-full text-left text-xs border-collapse min-w-[950px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
|
||||||
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
|
||||||
@@ -1140,26 +1192,38 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
|
||||||
Target Org / Grant
|
Target Org / Grant
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<th className="py-3.5 px-4 font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
|
<SortableTh column="createdAt" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} align="right" className="py-3.5 px-5 text-right">
|
||||||
Created Date
|
Created Date
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<th className="py-3.5 px-5 text-right font-semibold text-slate-400 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingInvites ? (
|
{loadingInvites ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
Loading invitations ledger...
|
Loading invitations ledger...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedInvites.length === 0 ? (
|
) : sortedInvites.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
No invitations recorded yet. Use “Dispatch Invitation” to send one.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedInvites.map((inv, idx) => (
|
sortedInvites.map((inv, idx) => {
|
||||||
|
const isApproved =
|
||||||
|
approvedInviteIds.includes(String(inv.id)) ||
|
||||||
|
approvedInviteIds.includes(`inv_${idx}`) ||
|
||||||
|
approvedInviteIds.includes(String(idx));
|
||||||
|
|
||||||
|
return (
|
||||||
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
|
||||||
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
|
||||||
{inv.recipient}
|
{inv.recipient}
|
||||||
@@ -1175,11 +1239,43 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
|
||||||
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
Org #{inv.invitedOrganisationId || inv.organisationId} • Award #{inv.awardId}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
{isApproved ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Approved
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||||
|
<RotateCcw className="w-3 h-3" />
|
||||||
|
Pending Approval
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
|
||||||
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-5 text-right">
|
||||||
|
{isApproved ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400/90 font-medium">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Partner Notified
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleApproveInvite(inv, idx)}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border border-emerald-500/30 transition shadow-xs cursor-pointer"
|
||||||
|
title="Approve this invitation and issue InApp and Email notifications to the partner user"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
<span>Approve</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -2559,6 +2655,22 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2.5 p-3 rounded-xl bg-blue-950/20 border border-blue-900/40">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="inviteAutoApprove"
|
||||||
|
checked={inviteAutoApprove}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<label htmlFor="inviteAutoApprove" className="text-xs text-slate-300 cursor-pointer select-none">
|
||||||
|
<span className="font-semibold text-white block">Formally Approve Invitation & Notify Partner</span>
|
||||||
|
<span className="text-[11px] text-slate-400 leading-relaxed block mt-0.5">
|
||||||
|
Automatically marks invite as Approved and issues In-App and Email notifications directing the partner user to the Partner Portal (<strong className="text-blue-400 font-mono">https://partner.ifri.khongisa.co.za/</strong>).
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { apiClient } from './httpClient';
|
import { apiClient } from './httpClient';
|
||||||
import { toolsService } from './toolsService';
|
import { toolsService } from './toolsService';
|
||||||
|
import {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||||
|
notifyInviteApproved,
|
||||||
|
notifyNewUserCreated,
|
||||||
|
isApprovedOrActiveStatus,
|
||||||
|
} from './notificationRulesService';
|
||||||
|
|
||||||
|
export {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange,
|
||||||
|
notifyInviteApproved,
|
||||||
|
notifyNewUserCreated,
|
||||||
|
isApprovedOrActiveStatus,
|
||||||
|
};
|
||||||
import {
|
import {
|
||||||
getCurrentUserSub,
|
getCurrentUserSub,
|
||||||
getCurrentUserTenantId,
|
getCurrentUserTenantId,
|
||||||
@@ -64,6 +77,7 @@ import {
|
|||||||
AuditLog,
|
AuditLog,
|
||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
|
ApprovalStatus,
|
||||||
Tenant,
|
Tenant,
|
||||||
TenantOrganisation,
|
TenantOrganisation,
|
||||||
TenantUser,
|
TenantUser,
|
||||||
@@ -239,6 +253,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise<Organisation> => {
|
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', {
|
const res = await apiClient.put('/v1/api/governance/organisations', {
|
||||||
organisationId: toIntegerId(payload.organisationId),
|
organisationId: toIntegerId(payload.organisationId),
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
@@ -247,8 +262,16 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
|||||||
idNumber: payload.idNumber,
|
idNumber: payload.idNumber,
|
||||||
organisationUser: payload.organisationUser,
|
organisationUser: payload.organisationUser,
|
||||||
type: Number(payload.type),
|
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;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -622,6 +645,14 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
saveStoredInstruction(updatedInstrument);
|
saveStoredInstruction(updatedInstrument);
|
||||||
}
|
}
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Funding Instrument',
|
||||||
|
recordId: targetId!,
|
||||||
|
newStatus: payload.status,
|
||||||
|
title: payload.name,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
return updatedInstrument;
|
return updatedInstrument;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -667,12 +698,21 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
|
|||||||
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) {
|
||||||
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
ownerId = getUserIdByEmail(ownerId) || userSub || ownerId;
|
||||||
}
|
}
|
||||||
|
const progStatus = Number(payload.status) || 1;
|
||||||
const res = await apiClient.put('/v1/api/projects/programme', {
|
const res = await apiClient.put('/v1/api/projects/programme', {
|
||||||
programmeId: toIntegerId(payload.programmeId),
|
programmeId: toIntegerId(payload.programmeId),
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000',
|
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;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -725,6 +765,13 @@ export const updateProjectStatus = async (payload: UpdateProjectStatusRequest):
|
|||||||
projectId: toIntegerId(payload.projectId),
|
projectId: toIntegerId(payload.projectId),
|
||||||
status: Number(payload.status),
|
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[]> => {
|
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,
|
priority: Number(payload.priority) || 1,
|
||||||
status: Number(payload.status) || 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[]> => {
|
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),
|
amount: Number(payload.amount),
|
||||||
...(payload.status !== undefined ? { status: Number(payload.status) } : {}),
|
...(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> => {
|
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),
|
contractId: toIntegerId(payload.contractId),
|
||||||
status: Number(payload.status),
|
status: Number(payload.status),
|
||||||
});
|
});
|
||||||
|
if (isApprovedOrActiveStatus(payload.status)) {
|
||||||
|
notifyTenantManagersAndExecutivesOnRecordStatusChange({
|
||||||
|
recordType: 'Contract',
|
||||||
|
recordId: payload.contractId,
|
||||||
|
newStatus: payload.status,
|
||||||
|
}).catch(console.warn);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadContractFile = async (
|
export const uploadContractFile = async (
|
||||||
@@ -1078,6 +1148,13 @@ export const updateBudget = async (payload: UpdateBudgetRequest): Promise<void>
|
|||||||
budgetId: toIntegerId(payload.budgetId),
|
budgetId: toIntegerId(payload.budgetId),
|
||||||
status: Number(payload.status),
|
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> => {
|
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
||||||
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
||||||
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {});
|
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),
|
evidenceType: Number(payload.evidenceType),
|
||||||
status: Number(payload.status),
|
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,
|
name: payload.name,
|
||||||
status: Number(payload.status),
|
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[]> => {
|
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
|
||||||
@@ -1854,6 +1951,14 @@ export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void
|
|||||||
description: payload.description,
|
description: payload.description,
|
||||||
status: Number(payload.status),
|
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),
|
revisedBudget: Number(payload.revisedBudget),
|
||||||
status: Number(payload.status),
|
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),
|
status: Number(payload.status),
|
||||||
beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null,
|
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,
|
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||||
assignedTo: payload.assignedTo,
|
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),
|
status: Number(payload.status),
|
||||||
visitedAt: visitedAtIso,
|
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';
|
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> {
|
export async function preloadAndCacheAllRoleClaims(): Promise<void> {
|
||||||
const roles = await fetchAndCacheSystemRoles();
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
await Promise.allSettled(
|
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 {
|
try {
|
||||||
const roles = await fetchAndCacheSystemRoles();
|
const roles = await fetchAndCacheSystemRoles();
|
||||||
for (const r of roles) {
|
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) => {
|
claims.forEach((c) => {
|
||||||
const norm = normalizeClaim(c);
|
const norm = normalizeClaim(c);
|
||||||
if (norm.claimType && !claimsMap.has(norm.claimType)) {
|
if (norm.claimType && !claimsMap.has(norm.claimType)) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default defineConfig(async ({ command }) => {
|
|||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 600,
|
chunkSizeWarningLimit: 600,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
onwarn(warning, defaultHandler) {
|
onwarn(warning: any, defaultHandler: any) {
|
||||||
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
|
||||||
if (
|
if (
|
||||||
warning.code === 'INVALID_ANNOTATION' &&
|
warning.code === 'INVALID_ANNOTATION' &&
|
||||||
@@ -61,7 +61,7 @@ export default defineConfig(async ({ command }) => {
|
|||||||
defaultHandler(warning);
|
defaultHandler(warning);
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id: string) {
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
// 1. Heavy export and PDF document generation
|
// 1. Heavy export and PDF document generation
|
||||||
if (id.includes('xlsx')) {
|
if (id.includes('xlsx')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user