Optimised cach usage, and enforced maker checker

This commit is contained in:
2026-10-25 10:11:57 +02:00
parent 9724bbac24
commit aeb7542cfe
20 changed files with 277 additions and 428 deletions
@@ -186,7 +186,6 @@ export const AppLayout: React.FC = () => {
useQuery({ useQuery({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
// Query tenant endpoint to resolve tenant name if not provided in claims or profile // Query tenant endpoint to resolve tenant name if not provided in claims or profile
@@ -194,7 +193,6 @@ export const AppLayout: React.FC = () => {
queryKey: ['active-tenant-header', tenantId], queryKey: ['active-tenant-header', tenantId],
queryFn: () => getTenant(tenantId!), queryFn: () => getTenant(tenantId!),
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName, enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
staleTime: 5 * 60 * 1000,
}); });
// Ensure current logged-in user is registered in the email cache // Ensure current logged-in user is registered in the email cache
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{Number(contractToUpdate?.status) === ContractStatus.Active ? ( {Number(contractToUpdate?.status) === ContractStatus.Active ? (
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"> <div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Active / Executed (Immutable - Cannot Be Changed)</span> <span>Active / Executed</span>
</div> </div>
) : ( ) : (
(() => { (() => {
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
title="Approved evidence is immutable. Status cannot be modified." title="Approved evidence is immutable. Status cannot be modified."
> >
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable)</span> <span>Approved</span>
</span> </span>
) : ( ) : (
<> <>
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
const { data: users = [], isLoading } = useQuery<User[]>({ const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
// Filter active users and exclude the current logged-in user so they can never message themselves // Filter active users and exclude the current logged-in user so they can never message themselves
@@ -124,7 +124,6 @@ export function useInAppNotifications() {
}, },
// Live polling every 30 seconds // Live polling every 30 seconds
refetchInterval: 30000, refetchInterval: 30000,
staleTime: 10000,
}); });
// InApp notifications (strictly platform === InApp 5) for Bell & Counters // InApp notifications (strictly platform === InApp 5) for Bell & Counters
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory // Remove caching from all pages: zero stale time and zero cache retention
gcTime: 1000 * 60 * 10, // 10 minutes cache retention // Whenever a component needs data, it must call its corresponding endpoint(s)
refetchOnWindowFocus: false, staleTime: 0,
refetchOnReconnect: true, gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1, retry: 1,
}, },
}, },
}); });
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/** /**
* Invalidates queries while simultaneously bumping the mutation epoch. * Invalidates queries while simultaneously bumping the mutation epoch.
* This guarantees that the background refetch generates a fresh query parameter (_v) * This guarantees that the background refetch generates a fresh query parameter (_v)
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
{Number(cr.status) === ApprovalStatus.Approved ? ( {Number(cr.status) === ApprovalStatus.Approved ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable."> <span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
<Lock className="w-3 h-3 text-emerald-400" /> <Lock className="w-3 h-3 text-emerald-400" />
<span>Resolved &amp; Enacted (Immutable)</span> <span>Resolved &amp; Enacted</span>
</span> </span>
) : ( ) : (
<> <>
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
title="Approved and enacted change requests are immutable." title="Approved and enacted change requests are immutable."
> >
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Enacted (Immutable - Cannot Be Changed)</span> <span>Approved &amp; Enacted</span>
</div> </div>
) : ( ) : (
<select <select
@@ -69,6 +69,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider'; import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac'; import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection'; import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection'; import { RulesSection } from '../components/governance/RulesSection';
@@ -241,7 +242,6 @@ export const GovernancePage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({ const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
const { data: systemRoles = [] } = useQuery<Role[]>({ const { data: systemRoles = [] } = useQuery<Role[]>({
@@ -580,7 +580,7 @@ export const GovernancePage: React.FC = () => {
defaultValues: { defaultValues: {
name: '', name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved, status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail, ownerEmail: effectiveUserEmail,
}, },
}); });
@@ -608,6 +608,7 @@ export const GovernancePage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['organisations'] }); queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully'); success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false); setIsOrgModalOpen(false);
@@ -639,6 +640,7 @@ export const GovernancePage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['portfolios'] }); queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully'); success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false); setIsPortfolioModalOpen(false);
@@ -650,15 +652,43 @@ export const GovernancePage: React.FC = () => {
}, },
}); });
const approveProgrammeMutation = useMutation({
mutationFn: async (pr: Programme) => {
if (isMaker(pr.createdBy)) {
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
}
return updateProgramme({
programmeId: pr.id,
name: pr.name || '',
portfolioId: Number(pr.portfolioId),
status: ApprovalStatus.Approved,
ownerId: pr.ownedBy,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
},
onError: (err: unknown) => {
toastError('Programme Approval Failed', err);
},
});
const programmeMutation = useMutation({ const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => { mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy); const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) { if (editingProgramme) {
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
}
return updateProgramme({ return updateProgramme({
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) : 1) as ApprovalStatus, status: targetStatus,
ownerId: resolvedOwnerId, ownerId: resolvedOwnerId,
}); });
} }
@@ -669,7 +699,9 @@ export const GovernancePage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] }); queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully'); success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false); setIsProgrammeModalOpen(false);
setEditingProgramme(null); setEditingProgramme(null);
@@ -755,7 +787,7 @@ export const GovernancePage: React.FC = () => {
programmeForm.reset({ programmeForm.reset({
name: '', name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved, status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail, ownerEmail: effectiveUserEmail,
}); });
setIsProgrammeModalOpen(true); setIsProgrammeModalOpen(true);
@@ -833,6 +865,7 @@ export const GovernancePage: React.FC = () => {
status: (p) => getApprovalStatusBadge(p.status).label, status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt, createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy), ownedBy: (p) => getUserEmail(p.ownedBy),
createdBy: (p) => getUserEmail(p.createdBy),
}); });
// Export Columns Definitions // Export Columns Definitions
@@ -863,6 +896,7 @@ export const GovernancePage: React.FC = () => {
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length, format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
}, },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) }, { header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) }, { header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' }, { header: 'Description', key: 'description', format: (val) => val || '—' },
]; ];
@@ -881,6 +915,7 @@ export const GovernancePage: React.FC = () => {
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label }, { header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) }, { header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) }, { header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
]; ];
return ( return (
@@ -1382,19 +1417,22 @@ export const GovernancePage: React.FC = () => {
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4"> <SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email Owner Email
</SortableTh> </SortableTh>
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Created By
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th> <th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800/60"> <tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? ( {loadingProgrammes ? (
<tr> <tr>
<td colSpan={6} className="py-8 text-center text-slate-500"> <td colSpan={7} className="py-8 text-center text-slate-500">
Loading programmes... Loading programmes...
</td> </td>
</tr> </tr>
) : sortedProgrammes.length === 0 ? ( ) : sortedProgrammes.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="py-8 text-center text-slate-500"> <td colSpan={7} className="py-8 text-center text-slate-500">
No programmes found matching query. No programmes found matching query.
</td> </td>
</tr> </tr>
@@ -1402,6 +1440,7 @@ export const GovernancePage: React.FC = () => {
sortedProgrammes.map((pr) => { sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId)); const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status); const statusBadge = getApprovalStatusBadge(pr.status);
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
return ( return (
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors"> <tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5"> <td className="py-3.5 px-5">
@@ -1420,8 +1459,49 @@ export const GovernancePage: React.FC = () => {
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]"> <td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
{getUserEmail(pr.ownedBy)} {getUserEmail(pr.ownedBy)}
</td> </td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{getUserEmail(pr.createdBy)}
</td>
<td className="py-3.5 px-5 text-right"> <td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
{isApproved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved programmes cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit Programme"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
{canApproveRecord('governance', pr.createdBy) && (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={() => approveProgrammeMutation.mutate(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Programme"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
{canApprove('governance') && isMaker(pr.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)}
</>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -1458,14 +1538,6 @@ export const GovernancePage: React.FC = () => {
<Scale className="w-3 h-3" /> <Scale className="w-3 h-3" />
Rules Rules
</button> </button>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -2004,18 +2076,18 @@ export const GovernancePage: React.FC = () => {
placement="top" placement="top"
/> />
</div> </div>
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? ( {editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
<div <div
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default" className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
title="Approved programmes are immutable." title="Approved programmes cannot be modified."
> >
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span> <span>Approved</span>
</div> </div>
) : ( ) : (
(() => { (() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy); const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy); const isSelf = isMaker(editingProgramme?.createdBy);
return ( return (
<select <select
@@ -2717,13 +2789,38 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span> <span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span> <span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div> </div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label} {getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span> </span>
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={async () => {
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
<span>Approve Programme</span>
</button>
) : (
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)
)
)}
</div>
</div> </div>
{/* Core Details Grid: Owner & Creation Date */} {/* Core Details Grid: Owner, Maker, Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{/* Creation Date */} {/* Creation Date */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5"> <div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-slate-400 shrink-0" /> <Calendar className="w-4 h-4 text-slate-400 shrink-0" />
@@ -2745,6 +2842,17 @@ export const GovernancePage: React.FC = () => {
</span> </span>
</div> </div>
</div> </div>
{/* Maker */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
</span>
</div>
</div>
</div> </div>
{/* Metrics: Total Rules and Total Instructions */} {/* Metrics: Total Rules and Total Instructions */}
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils'; import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort'; import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac'; import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { import {
Award, Award,
Budget, Budget,
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
const updateAwardMutation = useMutation({ const updateAwardMutation = useMutation({
mutationFn: (data: UpdateAwardRequest) => updateAward(data), mutationFn: (data: UpdateAwardRequest) => updateAward(data),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['awards'] }); queryClient.invalidateQueries({ queryKey: ['awards'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Award Updated', 'Award details saved successfully.'); success('Award Updated', 'Award details saved successfully.');
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
const budgetMutation = useMutation({ const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data), mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] }); queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget line established', 'Budget line created against project'); success('Budget line established', 'Budget line created against project');
setIsBudgetModalOpen(false); setIsBudgetModalOpen(false);
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
const updateBudgetMutation = useMutation({ const updateBudgetMutation = useMutation({
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data), mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] }); queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget Updated', 'Budget compliance and approval status updated.'); success('Budget Updated', 'Budget compliance and approval status updated.');
setIsEditBudgetModalOpen(false); setIsEditBudgetModalOpen(false);
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
}); });
}, },
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] }); queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success( success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled', editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
const approveMutation = useMutation({ const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id), mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => { onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] }); queryClient.invalidateQueries({ queryKey: ['disbursements'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] }); queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success( success(
@@ -826,8 +832,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
refetchAwards(); bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['awards'] });
await refetchAwards();
success('Refreshed', 'Awards intake list refreshed from API.'); success('Refreshed', 'Awards intake list refreshed from API.');
}} }}
disabled={isFetchingAwards} disabled={isFetchingAwards}
@@ -919,10 +927,10 @@ export const GrantsPage: React.FC = () => {
{Number(awd.status) === ApprovalStatus.Approved ? ( {Number(awd.status) === ApprovalStatus.Approved ? (
<span <span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved award is immutable. Details cannot be modified." title="Approved award details cannot be modified."
> >
<Lock className="w-3 h-3 text-emerald-400" /> <Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span> <span>Approved</span>
</span> </span>
) : ( ) : (
<button <button
@@ -973,8 +981,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
refetchBudgets(); bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
await refetchBudgets();
success('Refreshed', 'Budget line categories refreshed from API.'); success('Refreshed', 'Budget line categories refreshed from API.');
}} }}
disabled={isFetchingBudgets} disabled={isFetchingBudgets}
@@ -1059,10 +1069,10 @@ export const GrantsPage: React.FC = () => {
{Number(b.status) === ApprovalStatus.Approved ? ( {Number(b.status) === ApprovalStatus.Approved ? (
<span <span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default" className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved budget is immutable. Status cannot be modified." title="Approved budget status cannot be modified."
> >
<Lock className="w-3 h-3 text-emerald-400" /> <Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span> <span>Approved</span>
</span> </span>
) : ( ) : (
<> <>
@@ -1133,8 +1143,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
refetchDisbursements(); bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
await refetchDisbursements();
success('Refreshed', 'Disbursement tranches refreshed from API.'); success('Refreshed', 'Disbursement tranches refreshed from API.');
}} }}
disabled={isFetchingDisbursements} disabled={isFetchingDisbursements}
@@ -1549,7 +1561,7 @@ export const GrantsPage: React.FC = () => {
onSubmit={editAwardForm.handleSubmit((values) => { onSubmit={editAwardForm.handleSubmit((values) => {
if (!editingAward) return; if (!editingAward) return;
if (Number(editingAward.status) === ApprovalStatus.Approved) { if (Number(editingAward.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved awards cannot be modified.'); toastError('Record Approved', 'Approved awards cannot be modified.');
return; return;
} }
updateAwardMutation.mutate({ updateAwardMutation.mutate({
@@ -1630,7 +1642,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingAward?.status) === ApprovalStatus.Approved ? ( {Number(editingAward?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"> <div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span> <span>Approved</span>
</div> </div>
) : ( ) : (
<select <select
@@ -1783,7 +1795,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingBudget?.status) === ApprovalStatus.Approved ? ( {Number(editingBudget?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"> <div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Status Cannot Be Changed)</span> <span>Approved</span>
</div> </div>
) : ( ) : (
<select <select
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
const { data: directoryUsers = [] } = useQuery<User[]>({ const { data: directoryUsers = [] } = useQuery<User[]>({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
const { const {
@@ -152,7 +152,6 @@ export const SettingsPage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({ const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'], queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200), queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
}); });
const { const {
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
title="Approved site visits are immutable." title="Approved site visits are immutable."
> >
<Lock className="w-3.5 h-3.5 text-emerald-400" /> <Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Verified (Immutable - Cannot Be Changed)</span> <span>Approved &amp; Verified</span>
</div> </div>
) : ( ) : (
<select <select
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
} = useQuery<TenantOrganisation[]>({ } = useQuery<TenantOrganisation[]>({
queryKey: ['tenant-organisations', tenantId], queryKey: ['tenant-organisations', tenantId],
queryFn: () => getTenantOrganisations(tenantId), queryFn: () => getTenantOrganisations(tenantId),
staleTime: 30000,
}); });
// Fetch tenant users directory to resolve linked user email per organisation // Fetch tenant users directory to resolve linked user email per organisation
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({ const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId], queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId), queryFn: () => getTenantUsers(tenantId),
staleTime: 5 * 60 * 1000,
}); });
React.useEffect(() => { React.useEffect(() => {
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({ const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId], queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId), queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
}); });
const { canAdd } = useRbac(); const { canAdd } = useRbac();
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
} = useQuery<Tenant[]>({ } = useQuery<Tenant[]>({
queryKey: ['tenants-list'], queryKey: ['tenants-list'],
queryFn: () => getTenants(), queryFn: () => getTenants(),
staleTime: 30000,
}); });
// Forms // Forms
+12 -268
View File
@@ -442,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
} }
}; };
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions'; export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => { export const getStoredInstructions = (): Instrument[] => [];
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
if (!raw) return;
const local: Instrument[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiItems.map((i) => String(i.id))); export const saveStoredInstruction = (_inst?: Instrument): void => {};
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored instruction duplicates', e);
}
};
export const getStoredInstructions = (): Instrument[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredInstruction = (inst: Instrument): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredInstructions();
const idx = current.findIndex(
(i) => String(i.id) === String(inst.id) ||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else {
current.unshift(inst);
}
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored instruction', e);
}
};
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => { export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instrument[] = [];
let apiSucceeded = false;
try { try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId); const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`); const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
if (Array.isArray(res.data)) { return Array.isArray(res.data) ? res.data : [];
apiItems = res.data;
apiSucceeded = true;
}
} catch (err) { } catch (err) {
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err); console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
return [];
} }
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
return localItems;
}; };
export const getInstructions = getInstruments; export const getInstructions = getInstruments;
@@ -535,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
} }
const map = new Map<string, Instrument>(); const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) { if (progList && progList.length > 0) {
const results = await Promise.allSettled( const results = await Promise.allSettled(
progList.map((pr) => getInstruments(pr.id)) progList.map((pr) => getInstruments(pr.id))
); );
results.forEach((res) => { results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) { if (res.status === 'fulfilled' && Array.isArray(res.value)) {
hasApiData = true;
res.value.forEach((inst) => map.set(String(inst.id), inst)); res.value.forEach((inst) => map.set(String(inst.id), inst));
} }
}); });
} }
return Array.from(map.values());
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
}; };
export const getAllInstructions = getAllInstruments; export const getAllInstructions = getAllInstruments;
@@ -569,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
description: payload.description, description: payload.description,
}; };
try {
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body); const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
purgeStoredInstructionDuplicates([res.data]);
return res.data; return res.data;
}
} catch (err: any) {
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackInstruction: Instrument = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
saveStoredInstruction(fallbackInstruction);
return fallbackInstruction;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
}; };
export const createInstruction = createInstrument; export const createInstruction = createInstrument;
@@ -619,32 +514,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
status: Number(payload.status), status: Number(payload.status),
}; };
try {
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body); const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
return res.data;
}
} catch (err) {
console.warn('[updateInstrument] API update failed:', err);
}
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
const updatedInstrument: Instrument = {
id: targetId!,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null,
name: payload.name,
description: payload.description,
status: Number(payload.status),
createdBy: existing?.createdBy || 'system',
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId || 'system',
updatedAt: new Date().toISOString(),
};
if (existing) {
saveStoredInstruction(updatedInstrument);
}
if (isApprovedOrActiveStatus(payload.status)) { if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({ notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Funding Instrument', recordType: 'Funding Instrument',
@@ -653,7 +523,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
title: payload.name, title: payload.name,
}).catch(console.warn); }).catch(console.warn);
} }
return updatedInstrument; return res.data;
}; };
export const updateInstruction = updateInstrument; export const updateInstruction = updateInstrument;
@@ -2246,76 +2116,21 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
} }
}; };
const LOCAL_RULES_KEY = 'pfm_local_rules'; export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => { export const getStoredRules = (): Rule[] => [];
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
const apiIds = new Set(apiRules.map((r) => String(r.id))); export const saveStoredRule = (_rule?: Rule): void => {};
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored rule duplicates', e);
}
};
export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredRules();
const idx = current.findIndex(
(r) => String(r.id) === String(rule.id) ||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else {
current.unshift(rule);
}
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored rule', e);
}
};
// ========================================== // ==========================================
// 15. Execution: Programme Rules APIs // 15. Execution: Programme Rules APIs
// ========================================== // ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => { export const getRule = async (ruleId: string | number): Promise<Rule> => {
try {
const hashedRuleId = await toolsService.encodeId(ruleId); const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`); const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) { if (res.data) {
purgeStoredRuleDuplicates([res.data]);
return res.data; return res.data;
} }
} catch {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
if (localRule) return localRule;
throw new Error('Rule not found');
}
throw new Error('Rule not found'); throw new Error('Rule not found');
}; };
@@ -2332,38 +2147,16 @@ export const getRules = async (
pagination: Pagination = { page: 1, pageSize: 100 }, pagination: Pagination = { page: 1, pageSize: 100 },
programmeId?: string | number programmeId?: string | number
): Promise<Rule[]> => { ): Promise<Rule[]> => {
let apiRules: Rule[] = [];
let apiSucceeded = false;
try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', { const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0, index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1, page: pagination.page || 1,
pageSize: pagination.pageSize || 100, pageSize: pagination.pageSize || 100,
}); });
if (Array.isArray(res.data)) { let result = Array.isArray(res.data) ? res.data : [];
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
if (apiSucceeded) {
purgeStoredRuleDuplicates(apiRules);
let result = apiRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') { if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId)); result = result.filter((r) => String(r.programmeId) === String(programmeId));
} }
return result; return result;
}
// Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
}
return combined;
}; };
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => { export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
@@ -2375,44 +2168,12 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
description: payload.description, description: payload.description,
}; };
try {
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body); const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
if (res.data && res.data.id) {
purgeStoredRuleDuplicates([res.data]);
return res.data; return res.data;
}
} catch (err) {
console.warn('[createRule] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
saveStoredRule(fallbackRule);
return fallbackRule;
}
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
}; };
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => { export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId); const validUserId = toValidUuid(payload.userId);
try {
await apiClient.put('/v1/api/execution/rules', { await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId), ruleId: toIntegerId(payload.ruleId),
userId: validUserId, userId: validUserId,
@@ -2422,23 +2183,6 @@ export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
version: Number(payload.version || 1), version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}), ...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
}); });
} catch (err) {
console.warn('[updateRule] API update failed:', err);
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
createdBy: existing?.createdBy || validUserId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: validUserId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
}
}; };
// ========================================== // ==========================================
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
headers: { headers: {
'Content-Type': 'application/json; version=1', 'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1', Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
}, },
timeout: 15000, timeout: 15000,
}); });
@@ -81,36 +83,18 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') || config.url?.includes('amazonaws.com') ||
config.url?.includes('garage'); config.url?.includes('garage');
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management // Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
if (config.method?.toUpperCase() === 'GET' && !isExternal) { if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
// Explicit freshness requested: attach fresh timestamp
if (config.params instanceof URLSearchParams) { if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now())); config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} else { } else {
config.params = config.params || {}; config.params = config.params || {};
config.params._t = Date.now(); config.params._t = Date.now();
}
} else {
// Standard GET: attach current mutation epoch
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch(); config.params._v = getMutationEpoch();
} }
} }
}
}
// User Sync & Tenant Lifecycle Guard: // User Sync & Tenant Lifecycle Guard:
// All API calls require an authenticated and enabled user in the backend system. // All API calls require an authenticated and enabled user in the backend system.
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
statusCode?: number; statusCode?: number;
} }
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void { export function clearTenantLockoutCache(): void {
cachedTenantLockout = null; // Caching removed to guarantee fresh tenant state on every verification
} }
export function isStatusActive(status: any): boolean { export function isStatusActive(status: any): boolean {
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
candidateId = tenantClaim; candidateId = tenantClaim;
} }
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase(); // Fresh check: query endpoint directly without cache
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
let tenantRes: Tenant | null = null; let tenantRes: Tenant | null = null;
let lastError: any = null; let lastError: any = null;
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
isActive: true, isActive: true,
tenant: tenantObj, tenant: tenantObj,
}; };
cachedTenantLockout = { // Direct return without caching
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
return successResult; return successResult;
} }
@@ -293,7 +293,6 @@ export function DashboardPage() {
queryKey: ['partner-applicant-data', applicantUserId], queryKey: ['partner-applicant-data', applicantUserId],
queryFn: () => getApplicantData(applicantUserId), queryFn: () => getApplicantData(applicantUserId),
enabled: Boolean(applicantUserId), enabled: Boolean(applicantUserId),
staleTime: 30_000,
}); });
// Extract all unique projects, contracts, awards, and organisations from ApplicantData[] // Extract all unique projects, contracts, awards, and organisations from ApplicantData[]
@@ -426,7 +425,6 @@ export function DashboardPage() {
return Object.fromEntries(results); return Object.fromEntries(results);
}, },
enabled: partnerSummary.projectEntries.length > 0, enabled: partnerSummary.projectEntries.length > 0,
staleTime: 30_000,
}); });
// Combine into EnrichedPartnerProject[] // Combine into EnrichedPartnerProject[]
@@ -113,7 +113,6 @@ export function useInAppNotifications() {
return list || []; return list || [];
}, },
refetchInterval: 30000, refetchInterval: 30000,
staleTime: 10000,
}); });
const inAppNotifications = useMemo(() => { const inAppNotifications = useMemo(() => {
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory // Remove caching from all pages: zero stale time and zero cache retention
gcTime: 1000 * 60 * 10, // 10 minutes cache retention // Whenever a component needs data, it must call its corresponding endpoint(s)
refetchOnWindowFocus: false, staleTime: 0,
refetchOnReconnect: true, gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1, retry: 1,
}, },
}, },
}); });
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/** /**
* Invalidates queries while simultaneously bumping the mutation epoch. * Invalidates queries while simultaneously bumping the mutation epoch.
*/ */
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
headers: { headers: {
'Content-Type': 'application/json; version=1', 'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1', Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
}, },
timeout: 15000, timeout: 15000,
}); });
@@ -58,33 +60,18 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') || config.url?.includes('amazonaws.com') ||
config.url?.includes('garage'); config.url?.includes('garage');
// Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
if (config.method?.toUpperCase() === 'GET' && !isExternal) { if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
if (config.params instanceof URLSearchParams) { if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now())); config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} else { } else {
config.params = config.params || {}; config.params = config.params || {};
config.params._t = Date.now(); config.params._t = Date.now();
}
} else {
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch(); config.params._v = getMutationEpoch();
} }
} }
}
}
if (token && !isLifecycleRequest) { if (token && !isLifecycleRequest) {
if (isActivationPending()) { if (isActivationPending()) {