;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
- const { canAdd, canApprove, canExport } = useRbac();
+ const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -1982,16 +1982,33 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
-
+ {(() => {
+ const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
+ const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
+
+ return (
+
+ );
+ })()}
diff --git a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
index 4329aec..7179395 100644
--- a/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
+++ b/PostFundManagement.ConsoleUI/src/pages/GrantsPage.tsx
@@ -99,7 +99,7 @@ type DisbursementFormValues = z.infer;
export const GrantsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
- const { canAdd, canApprove, canExport } = useRbac();
+ const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [activeTab, setActiveTab] = useState<'awards' | 'budgets' | 'disbursements'>('awards');
@@ -1002,7 +1002,7 @@ export const GrantsPage: React.FC = () => {
Update
- {b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && (
+ {b.status !== ApprovalStatus.Approved && canApproveRecord('grant_financials', b.createdBy) && (
)}
+ {b.status !== ApprovalStatus.Approved && canApprove('grant_financials') && isMaker(b.createdBy) && (
+
+ Maker-Checker
+
+ )}
@@ -1159,7 +1164,7 @@ export const GrantsPage: React.FC = () => {
>
- {canApprove('grant_financials') && (
+ {canApproveRecord('grant_financials', d.createdBy) && (
)}
+ {canApprove('grant_financials') && isMaker(d.createdBy) && (
+
+ Maker-Checker (Creator)
+
+ )}
>
)}
@@ -1562,9 +1572,21 @@ export const GrantsPage: React.FC = () => {
onChange={(e) => setSelectedBudgetStatus(Number(e.target.value) as ApprovalStatus)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-medium [&>option]:bg-slate-900 [&>option]:text-white"
>
-
+ {(() => {
+ const canApproveBudget = canApproveRecord('grant_financials', editingBudget?.createdBy);
+ const isSelf = isMaker(editingBudget?.createdBy);
+ let reason = '';
+ if (!canApprove('grant_financials')) {
+ reason = ' (Requires Update Permission)';
+ } else if (isSelf) {
+ reason = ' (Maker-Checker: Cannot approve own record)';
+ }
+ return (
+
+ );
+ })()}
diff --git a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx
index b1f2acf..9b7cb72 100644
--- a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx
+++ b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx
@@ -43,6 +43,7 @@ import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
+import { useRbac } from '../auth/rbac';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
@@ -100,6 +101,7 @@ export const WORKFLOW_STAGES = [
export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
+ const { canAdd, canExport } = useRbac();
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
@@ -435,7 +437,7 @@ export const MonitoringPage: React.FC = () => {
- {activeTab === 'contracts' && (
+ {activeTab === 'contracts' && canAdd('grant_financials') && (
)}
- {activeTab === 'indicators' && (
+ {activeTab === 'indicators' && canAdd('evidence_risks') && (
)}
- {activeTab === 'risks' && (
+ {activeTab === 'risks' && canAdd('evidence_risks') && (
{
{
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
- const { canAdd, canExport } = useRbac();
+ const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
// Filter & Search States
const [searchTerm, setSearchTerm] = useState('');
@@ -954,22 +954,41 @@ export const ProjectsPage: React.FC = () => {
{/* Governance Status */}
e.stopPropagation()}>
-
+ {(() => {
+ const canApproveThis = canApproveRecord('projects_milestones', p.createdBy);
+ const isSelf = isMaker(p.createdBy);
+ const canModifyStatus = canAdd('projects_milestones') || canApprove('projects_milestones');
+
+ return (
+
+ );
+ })()}
|
diff --git a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx
index 25f7ea5..b58cb10 100644
--- a/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx
+++ b/PostFundManagement.ConsoleUI/src/pages/SiteVisitsPage.tsx
@@ -54,7 +54,7 @@ type SiteVisitFormValues = z.infer;
export const SiteVisitsPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
- const { canAdd, canApprove, canExport } = useRbac();
+ const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const [selectedProjectId, setSelectedProjectId] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
@@ -509,16 +509,25 @@ export const SiteVisitsPage: React.FC = () => {
{...visitForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
>
- {APPROVAL_STATUS_OPTIONS.map((opt) => (
-
- ))}
+ {(() => {
+ const canApproveVisit = canApproveRecord('site_visits', editingVisit?.createdBy);
+ const isSelf = isMaker(editingVisit?.createdBy);
+ return APPROVAL_STATUS_OPTIONS.map((opt) => {
+ const isApprovedOpt = opt.value === ApprovalStatus.Approved;
+ const isOptDisabled = isApprovedOpt && !canApproveVisit;
+ let reason = '';
+ if (isApprovedOpt && !canApprove('site_visits')) {
+ reason = ' (Requires Update Permission)';
+ } else if (isApprovedOpt && isSelf) {
+ reason = ' (Maker-Checker: Cannot approve own record)';
+ }
+ return (
+
+ );
+ });
+ })()}
)}
diff --git a/PostFundManagement.ConsoleUI/src/services/httpClient.ts b/PostFundManagement.ConsoleUI/src/services/httpClient.ts
index 5a289c4..4d42323 100644
--- a/PostFundManagement.ConsoleUI/src/services/httpClient.ts
+++ b/PostFundManagement.ConsoleUI/src/services/httpClient.ts
@@ -5,6 +5,55 @@ import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './u
export const BASE_API_URL = getApiBaseUrl();
+// ---------------------------------------------------------------------------
+// Mutation Versioning & Cache-Busting Management
+// ---------------------------------------------------------------------------
+// ASP.NET Core OutputCaching caches GET responses based on Method + URI + QueryString.
+// To ensure that mutations (create, update, approve, delete) immediately invalidate
+// backend OutputCache without serving stale data, ConsoleUI tracks a mutation epoch.
+// Every successful POST/PUT/PATCH/DELETE bumps this epoch, ensuring subsequent GET
+// requests partition to a fresh cache key on the server.
+// ---------------------------------------------------------------------------
+
+const MUTATION_EPOCH_STORAGE_KEY = 'pfm_mutation_epoch';
+
+let activeMutationEpoch: number = (() => {
+ if (typeof window !== 'undefined') {
+ try {
+ const stored = sessionStorage.getItem(MUTATION_EPOCH_STORAGE_KEY);
+ if (stored) {
+ const parsed = Number(stored);
+ if (!isNaN(parsed) && parsed > 0) return parsed;
+ }
+ } catch {}
+ }
+ return Date.now();
+})();
+
+/**
+ * Returns the current client mutation epoch timestamp.
+ * Appended as `_v` to internal API GET requests to partition backend OutputCache entries.
+ */
+export function getMutationEpoch(): number {
+ return activeMutationEpoch;
+}
+
+/**
+ * Bumps the mutation epoch to the current timestamp.
+ * Called automatically after any successful POST/PUT/PATCH/DELETE request,
+ * or manually when cache busting is required.
+ * Ensures subsequent GET requests bypass stale ASP.NET Core OutputCache entries.
+ */
+export function bumpMutationEpoch(): number {
+ activeMutationEpoch = Date.now();
+ if (typeof window !== 'undefined') {
+ try {
+ sessionStorage.setItem(MUTATION_EPOCH_STORAGE_KEY, String(activeMutationEpoch));
+ } catch {}
+ }
+ return activeMutationEpoch;
+}
+
// Create Axios Instance
export const apiClient = axios.create({
baseURL: BASE_API_URL,
@@ -15,7 +64,7 @@ export const apiClient = axios.create({
timeout: 15000,
});
-// Request Interceptor to dynamically set baseURL and attach Bearer Token
+// Request Interceptor to dynamically set baseURL, attach Bearer Token, and apply cache-busting
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
// Dynamically synchronize baseURL from runtime configuration
@@ -25,9 +74,46 @@ apiClient.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
+ const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
+ const isExternal =
+ config.url?.startsWith('http://') ||
+ config.url?.startsWith('https://') ||
+ config.url?.includes('amazonaws.com') ||
+ config.url?.includes('garage');
+
+ // Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
+ 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) {
+ config.params.set('_t', String(Date.now()));
+ } else {
+ config.params = config.params || {};
+ 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();
+ }
+ }
+ }
+ }
+
// User Sync & Tenant Lifecycle Guard:
// All API calls require an authenticated and enabled user in the backend system.
- const isLifecycleRequest = isUserSyncEndpoint(config.url) || isTenantEndpoint(config.url);
if (token && !isLifecycleRequest) {
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
@@ -48,9 +134,17 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error)
);
-// Response Interceptor for global HTTP 401 detection
+// Response Interceptor for global HTTP 401 detection and mutation cache invalidation
apiClient.interceptors.response.use(
- (response) => response,
+ (response) => {
+ // If response was from a successful mutating HTTP method (POST, PUT, PATCH, DELETE),
+ // automatically bump the mutation epoch so subsequent refetches bypass stale OutputCache
+ const method = response.config?.method?.toLowerCase();
+ if (method && ['post', 'put', 'patch', 'delete'].includes(method)) {
+ bumpMutationEpoch();
+ }
+ return response;
+ },
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
diff --git a/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts b/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts
index 69ebf49..1523286 100644
--- a/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts
+++ b/PostFundManagement.ConsoleUI/src/services/roleClaimsCache.ts
@@ -5,33 +5,64 @@ import { ROLE_CLAIM_DEFINITIONS } from '../auth/rbac';
export const LOCAL_STORAGE_SYSTEM_ROLES_KEY = 'pfm_cache_system_roles';
export const LOCAL_STORAGE_ROLE_CLAIMS_PREFIX = 'pfm_cache_role_claims_';
export const LOCAL_STORAGE_SYSTEM_CLAIMS_KEY = 'pfm_cache_system_claims';
+export const LOCAL_STORAGE_RBAC_VERSION_KEY = 'pfm_rbac_cache_version';
+export const CURRENT_RBAC_CACHE_VERSION = 'v2.1';
+
+/**
+ * Invalidates and purges legacy cached claims if the schema version differs.
+ */
+export function ensureFreshRbacCache(): void {
+ if (typeof window === 'undefined') return;
+ try {
+ const version = localStorage.getItem(LOCAL_STORAGE_RBAC_VERSION_KEY);
+ if (version !== CURRENT_RBAC_CACHE_VERSION) {
+ // Clear legacy claims and caches
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && (key.startsWith(LOCAL_STORAGE_ROLE_CLAIMS_PREFIX) || key === 'pfm_user_claims')) {
+ keysToRemove.push(key);
+ }
+ }
+ keysToRemove.forEach((k) => localStorage.removeItem(k));
+ inMemoryRoleClaims.clear();
+ localStorage.setItem(LOCAL_STORAGE_RBAC_VERSION_KEY, CURRENT_RBAC_CACHE_VERSION);
+ }
+ } catch {}
+}
// In-memory runtime caches
let inMemorySystemRoles: Role[] | null = null;
const inMemoryRoleClaims = new Map();
let inMemorySystemClaims: Claim[] | null = null;
+const inMemoryUserRoles = new Map();
+const inMemoryUserClaims = new Map();
+const USER_RBAC_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
+
+// Initial check on load
+ensureFreshRbacCache();
// Standard role claim defaults matching PFM Identity Server specification
export const DEFAULT_ROLE_CLAIMS: Record = {
contributor: [
- { claimId: 30, claimType: 'projects_milestones', claimValue: 'view, edit, update' },
- { claimId: 31, claimType: 'grant_financials', claimValue: 'view, edit, update' },
- { claimId: 32, claimType: 'evidence_risks', claimValue: 'view, edit, update' },
- { claimId: 33, claimType: 'beneficiaries', claimValue: 'view, edit, update' },
- { claimId: 34, claimType: 'change_management', claimValue: 'view, edit, update' },
- { claimId: 35, claimType: 'site_visits', claimValue: 'view, edit, update' },
- { claimId: 36, claimType: 'settings_invites', claimValue: 'view, edit, update' },
+ { claimId: 30, claimType: 'projects_milestones', claimValue: 'view, add' },
+ { claimId: 31, claimType: 'grant_financials', claimValue: 'view, add' },
+ { claimId: 32, claimType: 'evidence_risks', claimValue: 'view, add' },
+ { claimId: 33, claimType: 'beneficiaries', claimValue: 'view, add' },
+ { claimId: 34, claimType: 'change_management', claimValue: 'view, add' },
+ { claimId: 35, claimType: 'site_visits', claimValue: 'view, add' },
+ { claimId: 36, claimType: 'settings_invites', claimValue: 'view, add' },
],
supervisor: [
- { claimId: 20, claimType: 'dashboard', claimValue: 'view, add, export' },
- { claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, export' },
- { claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, export' },
- { claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, export' },
- { claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, export' },
- { claimId: 25, claimType: 'change_management', claimValue: 'view, add, export' },
- { claimId: 26, claimType: 'site_visits', claimValue: 'view, add, export' },
- { claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, export' },
- { claimId: 28, claimType: 'settings_users', claimValue: 'view, add, export' },
+ { claimId: 20, claimType: 'dashboard', claimValue: 'view, export' },
+ { claimId: 21, claimType: 'projects_milestones', claimValue: 'view, add, update, export' },
+ { claimId: 22, claimType: 'grant_financials', claimValue: 'view, add, update, export' },
+ { claimId: 23, claimType: 'evidence_risks', claimValue: 'view, add, update, export' },
+ { claimId: 24, claimType: 'beneficiaries', claimValue: 'view, add, update, export' },
+ { claimId: 25, claimType: 'change_management', claimValue: 'view, add, update, export' },
+ { claimId: 26, claimType: 'site_visits', claimValue: 'view, add, update, export' },
+ { claimId: 27, claimType: 'settings_invites', claimValue: 'view, add, update, export' },
+ { claimId: 28, claimType: 'settings_users', claimValue: 'view, add, update, export' },
],
manager: [
{ claimId: 10, claimType: 'dashboard', claimValue: 'all' },
@@ -154,8 +185,14 @@ export function setCachedRoleClaims(roleKey: string, claims: Claim[]): void {
/**
* Fetches system roles from backend API, caching the result in memory and localStorage.
+ * Checks cache first to avoid redundant network round trips.
*/
-export async function fetchAndCacheSystemRoles(): Promise {
+export async function fetchAndCacheSystemRoles(forceRefresh = false): Promise {
+ if (!forceRefresh) {
+ const cached = getCachedSystemRoles();
+ if (cached && cached.length > 0) return cached;
+ }
+
try {
const res = await apiClient.get('/v1/api/users/system/roles');
if (res.data && Array.isArray(res.data) && res.data.length > 0) {
@@ -181,30 +218,69 @@ export async function fetchAndCacheSystemRoles(): Promise {
return defaults;
}
+const GUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
/**
* Fetches role claims for a role from the API, caching them under role ID and role name.
+ * Uses cache-first strategy to eliminate sequential network waterfalls on startup.
*/
-export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string): Promise {
+export async function fetchAndCacheRoleClaims(
+ roleId: string,
+ roleName?: string,
+ forceRefresh = false
+): Promise {
const roleIdKey = roleId.toLowerCase().trim();
const roleNameKey = (roleName || '').toLowerCase().trim();
- try {
- const res = await apiClient.get(`/v1/api/users/system/roles/${roleId}/claims`);
- if (res.data && Array.isArray(res.data) && res.data.length > 0) {
- const normalized = res.data.map(normalizeClaim);
- setCachedRoleClaims(roleIdKey, normalized);
- if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
- return normalized;
+ // 1. Check in-memory and localStorage cache first
+ if (!forceRefresh) {
+ const cachedById = getCachedRoleClaims(roleIdKey);
+ if (cachedById && cachedById.length > 0) return cachedById;
+
+ if (roleNameKey) {
+ const cachedByName = getCachedRoleClaims(roleNameKey);
+ if (cachedByName && cachedByName.length > 0) return cachedByName;
}
- } catch (err) {
- // Network or server error, proceed to cache / defaults fallback
}
- // Check cache by role ID
+ // 2. Resolve actual GUID if roleId is not formatted as a GUID
+ let effectiveGuid = GUID_REGEX.test(roleId.trim()) ? roleId.trim() : null;
+ if (!effectiveGuid) {
+ const cachedRoles = getCachedSystemRoles();
+ if (cachedRoles && cachedRoles.length > 0) {
+ const match = cachedRoles.find(
+ (r) =>
+ (r.name && r.name.toLowerCase() === roleNameKey) ||
+ (r.name && r.name.toLowerCase() === roleIdKey) ||
+ String(r.id).toLowerCase() === roleIdKey
+ );
+ if (match && match.id && GUID_REGEX.test(String(match.id).trim())) {
+ effectiveGuid = String(match.id).trim();
+ }
+ }
+ }
+
+ if (effectiveGuid) {
+ try {
+ const res = await apiClient.get(`/v1/api/users/system/roles/${effectiveGuid}/claims`);
+ if (res.data && Array.isArray(res.data) && res.data.length > 0) {
+ const normalized = res.data.map(normalizeClaim);
+ setCachedRoleClaims(roleIdKey, normalized);
+ if (roleNameKey) setCachedRoleClaims(roleNameKey, normalized);
+ if (effectiveGuid.toLowerCase() !== roleIdKey) {
+ setCachedRoleClaims(effectiveGuid.toLowerCase(), normalized);
+ }
+ return normalized;
+ }
+ } catch (err) {
+ // Network or server error, proceed to cache / defaults fallback
+ }
+ }
+
+ // Re-check cache by role ID or name
const cachedById = getCachedRoleClaims(roleIdKey);
if (cachedById && cachedById.length > 0) return cachedById;
- // Check cache by role name
if (roleNameKey) {
const cachedByName = getCachedRoleClaims(roleNameKey);
if (cachedByName && cachedByName.length > 0) return cachedByName;
@@ -218,30 +294,118 @@ export async function fetchAndCacheRoleClaims(roleId: string, roleName?: string)
return normalized;
}
-/**
- * Direct API calls for user roles and user claims
- */
-export async function getUserRolesFromApi(userId: string): Promise {
- try {
- const res = await apiClient.get(`/v1/api/users/${userId}/roles`);
- return res.data || [];
- } catch {
- return [];
+export function clearCachedUserRbac(userId?: string): void {
+ if (userId) {
+ const key = userId.toLowerCase().trim();
+ inMemoryUserRoles.delete(key);
+ inMemoryUserClaims.delete(key);
+ if (typeof window !== 'undefined') {
+ try {
+ localStorage.removeItem(`pfm_cache_user_roles_${key}`);
+ localStorage.removeItem(`pfm_cache_user_claims_${key}`);
+ } catch {}
+ }
+ } else {
+ inMemoryUserRoles.clear();
+ inMemoryUserClaims.clear();
+ if (typeof window !== 'undefined') {
+ try {
+ const keysToRemove: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const k = localStorage.key(i);
+ if (k && (k.startsWith('pfm_cache_user_roles_') || k.startsWith('pfm_cache_user_claims_'))) {
+ keysToRemove.push(k);
+ }
+ }
+ keysToRemove.forEach((k) => localStorage.removeItem(k));
+ } catch {}
+ }
}
}
-export async function getUserClaimsFromApi(userId: string): Promise {
+/**
+ * Direct API calls for user roles and user claims with session-level caching
+ */
+export async function getUserRolesFromApi(userId: string, forceRefresh = false): Promise {
+ const key = userId.toLowerCase().trim();
+ if (!forceRefresh) {
+ const cached = inMemoryUserRoles.get(key);
+ if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
+ return cached.roles;
+ }
+ if (typeof window !== 'undefined') {
+ try {
+ const raw = localStorage.getItem(`pfm_cache_user_roles_${key}`);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ inMemoryUserRoles.set(key, { roles: parsed, timestamp: Date.now() });
+ return parsed;
+ }
+ }
+ } catch {}
+ }
+ }
+
+ try {
+ const res = await apiClient.get(`/v1/api/users/${userId}/roles`);
+ const roles = res.data || [];
+ if (roles.length > 0) {
+ inMemoryUserRoles.set(key, { roles, timestamp: Date.now() });
+ if (typeof window !== 'undefined') {
+ try {
+ localStorage.setItem(`pfm_cache_user_roles_${key}`, JSON.stringify(roles));
+ } catch {}
+ }
+ }
+ return roles;
+ } catch {
+ return inMemoryUserRoles.get(key)?.roles || [];
+ }
+}
+
+export async function getUserClaimsFromApi(userId: string, forceRefresh = false): Promise {
+ const key = userId.toLowerCase().trim();
+ if (!forceRefresh) {
+ const cached = inMemoryUserClaims.get(key);
+ if (cached && Date.now() - cached.timestamp < USER_RBAC_CACHE_TTL_MS) {
+ return cached.claims;
+ }
+ if (typeof window !== 'undefined') {
+ try {
+ const raw = localStorage.getItem(`pfm_cache_user_claims_${key}`);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed) && parsed.length > 0) {
+ const normalized = parsed.map(normalizeClaim);
+ inMemoryUserClaims.set(key, { claims: normalized, timestamp: Date.now() });
+ return normalized;
+ }
+ }
+ } catch {}
+ }
+ }
+
try {
const res = await apiClient.get(`/v1/api/users/${userId}/claims`);
- return res.data || [];
+ const claims = (res.data || []).map(normalizeClaim);
+ if (claims.length > 0) {
+ inMemoryUserClaims.set(key, { claims, timestamp: Date.now() });
+ if (typeof window !== 'undefined') {
+ try {
+ localStorage.setItem(`pfm_cache_user_claims_${key}`, JSON.stringify(claims));
+ } catch {}
+ }
+ }
+ return claims;
} catch {
- return [];
+ return inMemoryUserClaims.get(key)?.claims || [];
}
}
/**
* Resolves user roles, role claims, and user claims (including tenant and tenant_id)
- * and caches system roles and role claims as a persistent reference.
+ * concurrently using Promise.allSettled and caches system roles and claims.
*/
export async function resolveUserRolesAndClaims(
userSub: string,
@@ -254,27 +418,30 @@ export async function resolveUserRolesAndClaims(
tenant?: string;
tenant_id?: number;
}> {
+ ensureFreshRbacCache();
const mergedClaims: Record = { ...initialClaims };
const resolvedRoles: string[] = [];
+ let userRoleObjects: Role[] = [];
let primaryRole = currentProfile.role || (initialClaims.role as string) || '';
let tenant = currentProfile.tenant || (initialClaims.tenant as string) || undefined;
let tenant_id = currentProfile.tenant_id ?? (initialClaims.tenant_id ? Number(initialClaims.tenant_id) : undefined);
- // 1. Get user's roles from backend API (Requirement 2)
- if (userSub) {
- try {
- const userRoles = await getUserRolesFromApi(userSub);
- if (Array.isArray(userRoles) && userRoles.length > 0) {
- userRoles.forEach((r) => {
- if (r.name && !resolvedRoles.includes(r.name)) {
- resolvedRoles.push(r.name);
- }
- });
- if (resolvedRoles.length > 0) {
- primaryRole = resolvedRoles[0];
- }
+ // 1. Concurrently fetch user's roles and user's claims in parallel
+ const [rolesResult, claimsResult] = await Promise.allSettled([
+ userSub ? getUserRolesFromApi(userSub) : Promise.resolve([]),
+ userSub ? getUserClaimsFromApi(userSub) : Promise.resolve([]),
+ ]);
+
+ if (rolesResult.status === 'fulfilled' && Array.isArray(rolesResult.value) && rolesResult.value.length > 0) {
+ userRoleObjects = rolesResult.value;
+ rolesResult.value.forEach((r) => {
+ if (r.name && !resolvedRoles.includes(r.name)) {
+ resolvedRoles.push(r.name);
}
- } catch {}
+ });
+ if (resolvedRoles.length > 0) {
+ primaryRole = resolvedRoles[0];
+ }
}
// Fallback to initial role if API returned empty
@@ -282,55 +449,57 @@ export async function resolveUserRolesAndClaims(
resolvedRoles.push(primaryRole);
}
- // 2. For each role, retrieve role claims using the endpoint (Requirement 2 & 7)
+ // 2. Concurrently fetch role claims for all resolved roles in parallel
const allRoleClaims: Claim[] = [];
- for (const roleName of resolvedRoles) {
- try {
- const roleClaims = await fetchAndCacheRoleClaims(roleName, roleName);
- if (Array.isArray(roleClaims)) {
- roleClaims.forEach((rc) => {
- const norm = normalizeClaim(rc);
- allRoleClaims.push(norm);
- if (norm.claimType) {
- mergedClaims[norm.claimType] = norm.claimValue || 'all';
- }
- });
- }
- } catch {}
- }
+ const roleClaimsPromises = resolvedRoles.map(async (roleName) => {
+ const matchedRoleObj = userRoleObjects.find(
+ (r) => r.name && r.name.toLowerCase() === roleName.toLowerCase()
+ );
+ const roleIdToPass = matchedRoleObj?.id ? String(matchedRoleObj.id) : roleName;
+ return fetchAndCacheRoleClaims(roleIdToPass, roleName);
+ });
+
+ const roleClaimsResults = await Promise.allSettled(roleClaimsPromises);
+ roleClaimsResults.forEach((res) => {
+ if (res.status === 'fulfilled' && Array.isArray(res.value)) {
+ res.value.forEach((rc) => {
+ const norm = normalizeClaim(rc);
+ allRoleClaims.push(norm);
+ if (norm.claimType) {
+ mergedClaims[norm.claimType] = norm.claimValue || 'all';
+ }
+ });
+ }
+ });
+
if (allRoleClaims.length > 0) {
mergedClaims.role_claims = allRoleClaims;
}
- // 3. Retrieve user claims from endpoint (Requirement 3)
- if (userSub) {
- try {
- const userClaims = await getUserClaimsFromApi(userSub);
- if (Array.isArray(userClaims) && userClaims.length > 0) {
- userClaims.forEach((c) => {
- const norm = normalizeClaim(c);
- const type = (norm.claimType || '').toLowerCase().trim();
- const val = norm.claimValue;
- if (type === 'tenant' && val) {
- tenant = String(val);
- mergedClaims.tenant = String(val);
- } else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
- const parsed = Number(val);
- if (!isNaN(parsed) && isFinite(parsed)) {
- tenant_id = parsed;
- mergedClaims.tenant_id = parsed;
- }
- }
- if (norm.claimType) {
- mergedClaims[norm.claimType] = norm.claimValue;
- }
- });
- mergedClaims.user_claims = userClaims.map(normalizeClaim);
+ // 3. Process user claims
+ if (claimsResult.status === 'fulfilled' && Array.isArray(claimsResult.value) && claimsResult.value.length > 0) {
+ claimsResult.value.forEach((c) => {
+ const norm = normalizeClaim(c);
+ const type = (norm.claimType || '').toLowerCase().trim();
+ const val = norm.claimValue;
+ if (type === 'tenant' && val) {
+ tenant = String(val);
+ mergedClaims.tenant = String(val);
+ } else if ((type === 'tenant_id' || type === 'tenantid') && val !== undefined && val !== null) {
+ const parsed = Number(val);
+ if (!isNaN(parsed) && isFinite(parsed)) {
+ tenant_id = parsed;
+ mergedClaims.tenant_id = parsed;
+ }
}
- } catch {}
+ if (norm.claimType) {
+ mergedClaims[norm.claimType] = norm.claimValue;
+ }
+ });
+ mergedClaims.user_claims = claimsResult.value.map(normalizeClaim);
}
- // 4. Background preload all system roles & role claims for reference (Requirement 7)
+ // 4. Background preload all system roles & role claims for reference (non-blocking)
preloadAndCacheAllRoleClaims().catch(() => {});
return {
@@ -343,13 +512,13 @@ export async function resolveUserRolesAndClaims(
}
/**
- * Preloads and caches all system roles and their respective role claims.
+ * Preloads and caches all system roles and their respective role claims concurrently.
*/
export async function preloadAndCacheAllRoleClaims(): Promise {
const roles = await fetchAndCacheSystemRoles();
- for (const role of roles) {
- await fetchAndCacheRoleClaims(role.id, role.name);
- }
+ await Promise.allSettled(
+ roles.map((role) => fetchAndCacheRoleClaims(role.id, role.name))
+ );
}
/**
diff --git a/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts b/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts
index f47edfa..dfff62a 100644
--- a/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts
+++ b/PostFundManagement.ConsoleUI/src/services/tenantLockoutService.ts
@@ -9,6 +9,18 @@ export interface TenantLockoutResult {
statusCode?: number;
}
+// In-memory cache for fast tenant status checks (avoids repeated API round trips during navigations)
+let cachedTenantLockout: {
+ key: string;
+ result: TenantLockoutResult;
+ timestamp: number;
+} | null = null;
+const LOCKOUT_CACHE_TTL_MS = 60 * 1000; // 60 seconds
+
+export function clearTenantLockoutCache(): void {
+ cachedTenantLockout = null;
+}
+
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
@@ -63,7 +75,8 @@ export function getStatusLabel(status: any): string {
*/
export async function verifyTenantLockout(
tenantId?: number | string | null,
- tenantClaim?: string | null
+ tenantClaim?: string | null,
+ forceRefresh = false
): Promise {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
@@ -83,6 +96,16 @@ export async function verifyTenantLockout(
};
}
+ const cacheKey = String(primaryId).trim().toLowerCase();
+ 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 lastError: any = null;
@@ -147,8 +170,14 @@ export async function verifyTenantLockout(
}
// Success: Response object returned and Status is Active
- return {
+ const successResult: TenantLockoutResult = {
isActive: true,
tenant: tenantObj,
};
+ cachedTenantLockout = {
+ key: cacheKey,
+ result: successResult,
+ timestamp: Date.now(),
+ };
+ return successResult;
}
diff --git a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts
index a31d7be..078b57c 100644
--- a/PostFundManagement.ConsoleUI/src/services/userSyncService.ts
+++ b/PostFundManagement.ConsoleUI/src/services/userSyncService.ts
@@ -36,12 +36,22 @@ export interface UserSyncResult {
let inFlightSyncPromise: Promise | null = null;
+// In-memory cache to prevent synchronous localStorage parsing on every HTTP request
+let inMemorySyncRecord: UserSyncRecord | null = null;
+let inMemoryActivationPending: boolean | null = null;
+let inMemoryActivationReason: string | null = null;
+
/**
* Checks if the user is currently flagged in the browser as pending administrator activation / lockout.
*/
export function isActivationPending(): boolean {
+ if (inMemoryActivationPending !== null) {
+ return inMemoryActivationPending;
+ }
try {
- return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
+ const val = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY) === 'true';
+ inMemoryActivationPending = val;
+ return val;
} catch {
return false;
}
@@ -51,8 +61,13 @@ export function isActivationPending(): boolean {
* Gets the reason/error message recorded when activation check was last pending.
*/
export function getActivationPendingReason(): string | null {
+ if (inMemoryActivationReason !== null) {
+ return inMemoryActivationReason;
+ }
try {
- return localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
+ const reason = localStorage.getItem(LOCAL_STORAGE_ACTIVATION_REASON_KEY);
+ inMemoryActivationReason = reason;
+ return reason;
} catch {
return null;
}
@@ -62,6 +77,8 @@ export function getActivationPendingReason(): string | null {
* Sets or clears the pending activation flag in browser persistent storage.
*/
export function setActivationPending(pending: boolean, reason?: string): void {
+ inMemoryActivationPending = pending;
+ inMemoryActivationReason = reason || null;
try {
if (pending) {
localStorage.setItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY, 'true');
@@ -91,10 +108,15 @@ export function isUserSyncedInBrowser(currentSub?: string, currentToken?: string
return false;
}
- const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
- if (!raw) return false;
+ if (!inMemorySyncRecord) {
+ const raw = localStorage.getItem(LOCAL_STORAGE_USER_SYNC_KEY);
+ if (!raw) return false;
+ const parsed: UserSyncRecord = JSON.parse(raw);
+ if (!parsed || parsed.status !== 'synced') return false;
+ inMemorySyncRecord = parsed;
+ }
- const record: UserSyncRecord = JSON.parse(raw);
+ const record = inMemorySyncRecord;
if (!record || record.status !== 'synced') return false;
// Check if the stored sync matches the current user sub (if specified)
@@ -134,6 +156,7 @@ export function saveUserSyncInBrowser(
status: 'synced',
userData: userData || undefined,
};
+ inMemorySyncRecord = record;
localStorage.setItem(LOCAL_STORAGE_USER_SYNC_KEY, JSON.stringify(record));
} catch (err) {
console.warn('[UserSync] Failed to write sync record to localStorage:', err);
@@ -144,6 +167,9 @@ export function saveUserSyncInBrowser(
* Clears the user sync record from browser storage (used on logout or when activation fails).
*/
export function clearUserSyncInBrowser(): void {
+ inMemorySyncRecord = null;
+ inMemoryActivationPending = null;
+ inMemoryActivationReason = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
localStorage.removeItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY);
@@ -155,6 +181,7 @@ export function clearUserSyncInBrowser(): void {
* Clears only the successful sync record, leaving activation pending intact.
*/
export function clearSuccessfulSyncRecord(): void {
+ inMemorySyncRecord = null;
try {
localStorage.removeItem(LOCAL_STORAGE_USER_SYNC_KEY);
} catch {}
diff --git a/Processes.md b/Processes.md
new file mode 100644
index 0000000..7f1ffb1
--- /dev/null
+++ b/Processes.md
@@ -0,0 +1,167 @@
+# PostFundManagement (PFM) Platform — Operational Processes & Governance (Processes.md)
+
+> **Document Context**: Authoritative operational manual and process specification for the PFM Platform. Derived from the PFM SRS v2.0 and governing all lifecycle transitions, workflow gates, RBAC controls, and Maker-Checker segregation of duties.
+
+---
+
+## 1. PFM End-to-End Operational Lifecycle
+
+The PFM platform governs the lifecycle of grant, development, innovation, and impact funding portfolios from the moment an award is approved through long-term post-fund monitoring.
+
+```
+┌────────────────────────────────────────────────────────────────────────────────────────┐
+│ POST-FUND LIFECYCLE │
+│ │
+│ [1. Award Intake] ──> [2. Contracting] ──> [3. Onboarding] │
+│ External Approval Draft Agreement KYC & Banking Gate │
+│ Recorded (BR-001) Auto-created (BR-002) Required for Disb. (BR-003) │
+│ │ │
+│ ▼ │
+│ [4. Project & WBS] ──> [5. Budget & Lines] ──> [6. Disbursement Gate] │
+│ Milestones & Tasks Allocations & Caps Requires Verified Evidence │
+│ Progress Tracking No Over-commitment & Satisfied Conditions (BR-004) │
+│ │ │
+│ ▼ │
+│ [7. M&E & Evidence] ─> [8. Change & Risk] ──> [9. Close-Out & Impact] │
+│ Indicator Actuals Variations & Issues Post-Fund Monitoring │
+│ Field Site Visits Audit Trail (BR-007) Portfolio Roll-Up (BR-010) │
+└────────────────────────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 2. Core Operational Processes
+
+### 2.1 Process 1: Award Intake & Funding Registration (Module M01)
+* **Pre-condition**: Award has been approved by external sanctioning committee (BR-001).
+* **Actor**: Funding Administrator (`grant_financials: add`).
+* **Workflow**:
+ 1. Capture external reference number, award amount ($> 0$), funding period, programme, and recipient organisation.
+ 2. System enforces duplicate check against external reference numbers.
+ 3. Upon successful award creation, the system **automatically initializes** the corresponding legal `Contract` in `Draft` status (BR-002).
+
+### 2.2 Process 2: Contracting & Agreement Management (Module M02)
+* **Pre-condition**: Valid award record exists.
+* **Actors**: Legal / Programme Manager (`grant_financials: add`), Executive / Authorised Signatory (`grant_financials: update`).
+* **Workflow**:
+ 1. Author attaches legal terms, clauses, total value, and effective/expiry dates.
+ 2. Upload signed bilateral agreement PDF.
+ 3. Transition contract status to `Active`.
+ 4. **Maker-Checker Rule**: The user who uploaded or created the contract cannot be the signatory who transitions it to `Active`.
+
+### 2.3 Process 3: Project WBS & Milestones Management (Module M04 & M07)
+* **Pre-condition**: Project is in `Active` status.
+* **Actors**: Contributor / Project Manager (`projects_milestones: add`), Supervisor / Manager (`projects_milestones: update`).
+* **Workflow**:
+ 1. Project Manager defines contractual milestones with due dates and priority levels.
+ 2. Contributors log ground activities against milestones.
+ 3. Contributor uploads completion evidence.
+ 4. Status transitions from `Planned` $\to$ `In Progress` $\to$ `Submitted` $\to$ `Under Review` $\to$ `Verified & Approved`.
+ 5. **Verification Gate**: Only users with `projects_milestones: update` who **did not create** the milestone can verify and approve it.
+
+### 2.4 Process 4: Disbursement Control & Payment Release (Module M05 & M06)
+* **Pre-condition**: Recipient onboarding complete, banking details verified, and linked milestone deliverable verified.
+* **Actors**: Finance Officer (`grant_financials: add`), Finance Director / Manager (`grant_financials: update`).
+* **Workflow**:
+ 1. Finance Officer drafts disbursement tranche drawdown against verified milestone.
+ 2. System validates conditions precedent and budget caps (commits + expenditures $\le$ approved budget line).
+ 3. Authoriser verifies evidence and approves tranche release.
+ 4. **Maker-Checker Rule**: The user who captured the payment request cannot approve the disbursement.
+
+### 2.5 Process 5: M&E Indicators, Risks & Evidence Review (Module M08 & M09)
+* **Pre-condition**: Approved project indicators and risk registers configured.
+* **Actors**: M&E Officer (`evidence_risks: add`), Programme Supervisor (`evidence_risks: update`).
+* **Workflow**:
+ 1. Field staff or recipients submit indicator actuals and supporting verification files (`EvidenceType`).
+ 2. Evidence enters the `EvidenceReviewQueue`.
+ 3. Supervisor reviews document authenticity, geotags, and deliverable match.
+ 4. Decision: `Approve & Verify` or `Reject` (with mandatory rework reasoning).
+ 5. **Maker-Checker Rule**: The officer who uploaded the evidence document cannot approve their own submission.
+
+### 2.6 Process 6: Change Management & Variations (Module M15 & M16)
+* **Pre-condition**: Active project with identified issue or required contractual variance.
+* **Actors**: Contributor (`change_management: add`), Manager (`change_management: update`).
+* **Workflow**:
+ 1. Contributor logs project issue or drafts contractual change request (budget reallocation, timeline extension, scope shift).
+ 2. Impact assessment conducted.
+ 3. Governance deliberation.
+ 4. Resolution: `Approved & Enacted` or `Rejected`.
+ 5. **Maker-Checker Rule**: Change request initiator cannot approve or enact the change.
+
+---
+
+## 3. The Maker-Checker Operational Policy
+
+### 3.1 Policy Definition
+Under PFM enterprise governance standards, **dual authorization (Maker-Checker)** is mandatory for all material lifecycle decisions:
+
+$$\text{Approval Granted} \iff (\text{User has 'update' permission}) \land (\text{User ID} \neq \text{Record Creator ID})$$
+
+### 3.2 Matrix of Segregated Actions
+
+| Operational Domain | Maker Action (`add`) | Checker Action (`update`) | Maker-Checker Invariant |
+| :--- | :--- | :--- | :--- |
+| **Projects & Governance** | Register project, update scope | Approve project governance status | Creator cannot approve project |
+| **Contractual Milestones** | Define milestone, submit evidence | Verify deliverable, approve milestone | Milestone author cannot verify |
+| **Disbursements** | Capture tranche drawdown request | Authorize payment release | Requestor cannot release payment |
+| **Budgets** | Define budget line, allocate funds | Approve statutory budget line | Allocator cannot approve budget |
+| **Evidence Queue** | Upload proof, ingest documentation | Approve & verify evidence artifact | Submitter cannot verify evidence |
+| **Change Requests** | Draft scope / budget variation | Approve & enact variation | Initiator cannot enact change |
+| **Site Visits** | Log field checklist & observations | Approve site inspection report | Inspector cannot self-sign report |
+| **Programmes** | Create programme structure | Approve programme governance | Creator cannot approve programme |
+
+### 3.3 System Behavior on Maker-Checker Collisions
+When an authorised approver navigates to a record they personally created:
+1. **Dropdown Controls**: The `Approved` option is disabled with the label:
+ `Approved (Maker-Checker: Cannot approve own record)`
+2. **Action Buttons**: Fast-track approve buttons are hidden, and a visual tag `Maker-Checker (Creator)` is displayed.
+3. **Audit Trail**: Any attempt to bypass client controls logs a security violation in the immutable audit log (`settings_auditlog`).
+
+---
+
+## 4. RBAC Operational Roles & Boundaries
+
+### 4.1 Role Assignment Rules
+1. **`Contributor`**: Operational field execution. Strictly restricted to `view` and `add`. Contributors can create activities, submit evidence, and draft change requests, but **never approve records or export datasets**.
+2. **`Supervisor`**: Operational oversight. Possesses `view, add, update, export`. Verifies milestones, inspects evidence, reviews site visits, and exports reports, subject to the Maker-Checker rule.
+3. **`Manager`**: Comprehensive operational management. Possesses `all` across operational domains. Manages audit logs, notifications, and tenancy.
+4. **`Executive`**: Strategic leadership. Possesses `all` across strategic domains (`dashboard`, `governance`, `grant_financials`, `evidence_risks`).
+5. **`Admin`**: System administrator. Possesses `unrestricted: all`. Governs tenant boundaries, partitions, and system security.
+6. **`Partner`**: External grantee / recipient. Strictly confined to `PartnerUI`. Any attempt to access `ConsoleUI` triggers an immediate security lockout.
+
+---
+
+## 5. Caching & Mutation Invalidation Workflow
+
+To ensure that caching never impedes record lifecycle updates or displays stale data while maximizing UI responsiveness, the following operational flow is enforced across ConsoleUI:
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User as User / Maker
+ participant UI as ConsoleUI (React Query)
+ participant Client as Axios HttpClient
+ participant OutputCache as Backend OutputCache (Redis/Memory)
+ participant DB as PostgreSQL Database
+
+ User->>UI: Submits Record Mutation (Create / Update / Approve)
+ UI->>Client: Dispatches POST / PUT / PATCH / DELETE
+ Client->>DB: Executes Mutation in Database
+ DB-->>Client: Returns 200 OK / 201 Created
+ Client->>Client: Interceptor detects mutation: bumps activeMutationEpoch
+ UI->>UI: onSuccess triggers queryClient.invalidateQueries()
+ UI->>Client: Refetches active queries with _v = activeMutationEpoch
+ Client->>OutputCache: GET /v1/api/*?_v=
+ Note over OutputCache: Cache Miss (new _v key never seen before)
+ OutputCache->>DB: Queries PostgreSQL for fresh data
+ DB-->>OutputCache: Returns updated entities
+ OutputCache-->>UI: Caches fresh data under _v and returns to UI
+ UI-->>User: UI updates immediately with new status / record
+```
+
+### 5.1 Operating Rules for Record Modifications
+1. **Zero Manual Cache Eviction Required**: Frontend developers do not need to manually configure cache busters for standard operations. The Axios response interceptor in `httpClient.ts` automatically increments `activeMutationEpoch` on any successful mutating call (`POST`, `PUT`, `PATCH`, `DELETE`).
+2. **Deterministic Invalidation**: Calling standard `queryClient.invalidateQueries({ queryKey: [...] })` or `invalidateAndBustCache(queryClient, filters)` guarantees that the subsequent refetch bypasses stale ASP.NET Core OutputCache entries.
+3. **Optimized Read Cache**: When users navigate across unchanged views or switch tabs, identical `_v` tokens are reused, allowing backend OutputCache and React Query in-memory caches to fulfill requests in < 5ms without unnecessary database hits.
+
+
diff --git a/README.md b/README.md
index 7490ecd..e8e88d9 100644
--- a/README.md
+++ b/README.md
@@ -8,14 +8,16 @@ An enterprise system designed to manage the entire post-funding grant and projec
The application follows a modular, hierarchical architecture where high-level funding directives cascade into measurable project outcomes and evidence verification.
+```text
[Portfolios] ───> [Programmes] ───> [Projects] ───> [Awards] ───> [Contracts] ───> [Beneficiaries] ──> [Outcomes]
│ │ │
▼ │ ▼
[Rules] ├─> [Milestones] ──> [Activities] ──> [Evidences]
-├─> [Disbursements]
-├─> [Indicators]
-├─> [Risks] & [Issues]
-└─> [SiteVisits] & [ComplianceItems]
+ ├─> [Disbursements]
+ ├─> [Indicators]
+ ├─> [Risks] & [Issues]
+ └─> [SiteVisits] & [ComplianceItems]
+```
### **1. Portfolio & Programme Governance**
* **Portfolios**: Top-level containers for strategic investment themes.
@@ -55,45 +57,85 @@ The application follows a modular, hierarchical architecture where high-level fu
## 🛠️ Tech Stack & Patterns
-* **Backend Framework**: .NET 8 / 9 C# Web API
-* **Database**: PostgreSQL
-* **ORM**: Entity Framework Core (Fluent API Configuration)
-* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`MediatR`)
-* **Utilities**: `Humanizer` (Automated entity-to-table pluralization)
+* **Backend Framework**: .NET 10.0 (C# 13) Web API / Minimal APIs
+* **Frontend Portals**:
+ * `PostFundManagement.ConsoleUI`: Internal Staff & Portfolio Administration Portal (React 19 + TypeScript + Vite + Tailwind CSS)
+ * `PostFundManagement.PartnerUI`: External Recipient & Grantee Portal (React 19 + TypeScript + Vite + WebGL OGL)
+* **Database**: PostgreSQL 14+ with Npgsql & EF Core 10
+* **Security & Authentication**: Duende IdentityServer / OIDC PKCE + Claim-Based RBAC
+* **Governance Patterns**: Maker-Checker Pattern (Dual Authorization & Segregation of Duties)
+* **Architecture**: Clean Architecture / Domain-Driven Design (DDD) with CQRS (`Mediator`)
+* **Object Storage**: S3-compatible storage (Garage S3 / AWS S3)
+* **Caching & OutputCache Invalidation**: Redis StackExchange + ASP.NET Core `OutputCaching` (2-minute base policy) coupled with TanStack Query v5 and Axios client-side mutation versioning (`_v` parameter) for instantaneous reads and guaranteed zero-staleness on mutations
+* **Telemetry**: OpenTelemetry Metrics, Traces & Logs with Aspire / OTLP
+
+---
+
+## 🔒 Role-Based Access Control (RBAC) & Maker-Checker Pattern
+
+The platform implements strict governance invariants across all operational domains:
+
+### 1. Progressive Action Hierarchy
+* **`view`**: Read-only inspection of entity lists, details, and metrics.
+* **`add`**: Create new records, edit drafts, submit evidence, and adjust non-approval statuses.
+* **`update`**: Authoritative record **approval** authority (`Approved`, `Active` contracts).
+* **`export`**: Export tabular data and reports (`.xlsx`, `.pdf`).
+* **`all`**: Comprehensive authority across the claim domain.
+
+### 2. Role Claim Matrix
+* **`Admin`**: `unrestricted: all` — Full system-wide administration.
+* **`Executive`**: Strategic oversight across Dashboard, Governance, Projects, Grants, M&E (`all`).
+* **`Manager`**: Comprehensive operational control across all domains (`all`).
+* **`Supervisor`**: Operational oversight and verification (`view, add, update, export`).
+* **`Contributor`**: Working-level execution (`view, add`). Strictly barred from record approvals and exports.
+* **`Partner`**: External recipient. Confined strictly to `PartnerUI`; locked out of `ConsoleUI`.
+
+### 3. Maker-Checker Segregation of Duties
+To eliminate conflicts of interest, record creators ("Makers") cannot approve ("Check") their own records. Even if an officer possesses the `update` claim, the system dynamically blocks self-approval on any record matching their `createdBy` identifier:
+$$\text{Can Approve} = \text{Has 'update' permission} \land (\text{Current User ID} \neq \text{Record Creator ID})$$
+
+For in-depth architectural and operational specifications, see:
+* [Architecture.md](file:///c:/Users/Admin/source/repos/PostFundManagement/Architecture.md)
+* [Processes.md](file:///c:/Users/Admin/source/repos/PostFundManagement/Processes.md)
+* [GEMINI.md](file:///c:/Users/Admin/source/repos/PostFundManagement/GEMINI.md)
---
## 📁 Solution Structure
```text
-src/
- ├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
- ├── PostFundManagement.Infrastructure/ # EF Core Configurations, DbContext, Migrations
- ├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Logic
- └── PostFundManagement.Api/ # REST Controllers, Middleware, Auth & Swagger
+PostFundManagement/
+ ├── PostFundManagement.Domain/ # Core Domain Entities, Enums & Interfaces
+ ├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL, Skoruba STS, Migrations
+ ├── PostFundManagement.Application/ # CQRS Handlers, DTOs, Business Rules & Validations
+ ├── PostFundManagement.Api/ # REST Endpoints, Middleware, Auth & Swagger/Scalar
+ ├── PostFundManagement.Tests/ # Unit & Integration Test Suite
+ ├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite)
+ ├── PostFundManagement.PartnerUI/ # External Grantee / Recipient Portal (React 19 + Vite)
+ ├── Architecture.md # System Architecture, RBAC & Maker-Checker Spec
+ ├── Processes.md # Operational Grant Lifecycle & Process Flows
+ └── docker-compose.yml # Container orchestration
```
## 🚀 Getting Started
### Prerequisites
-.NET 8.0 SDK or higher
-PostgreSQL 14+
-dotnet-ef CLI Tool (dotnet tool install --global dotnet-ef)
+* .NET 10.0 SDK or higher
+* Node.js 20+ & Yarn (`yarn.lock` authoritative)
+* PostgreSQL 14+
## Setup & Execution
-## Clone Repository
-
-1. Clone Repository:
+### 1. Clone Repository:
```bash
-git clone [https://github.com/your-org/PostFundManagement.git](https://github.com/your-org/PostFundManagement.git)
+git clone https://github.com/your-org/PostFundManagement.git
cd PostFundManagement
```
-2. Configure Database Connection:
-Update appsettings.Development.json in the API project with your local PostgreSQL details:
+### 2. Configure Database Connection:
+Update `appsettings.Development.json` in the API project with your local PostgreSQL details:
```json
{
@@ -103,16 +145,18 @@ Update appsettings.Development.json in the API project with your local PostgreSQ
}
```
-3. Apply Database Migrations:
+### 3. Launch Backend API:
```bash
-dotnet ef database update --project src/PostFundManagement.Infrastructure
+dotnet run --project PostFundManagement.Api
```
-4. Launch Application:
+### 4. Launch ConsoleUI Frontend:
```bash
-dotnet run --project src/PostFundManagement.Api
+cd PostFundManagement.ConsoleUI
+yarn install
+yarn dev
```
## 📄 License