@@ -853,7 +869,7 @@ export const SettingsPage: React.FC = () => {
{u.id}
|
- {u.idNumber || —}
+
|
@@ -915,6 +931,64 @@ export const SettingsPage: React.FC = () => {
+
+ {/* Pagination Controls */}
+ {totalUsers > 0 && (
+
+
+
+ Showing {Math.min((userPage - 1) * userPageSize + 1, totalUsers)} to{' '}
+ {Math.min(userPage * userPageSize, totalUsers)} of{' '}
+ {totalUsers} users
+
+
+
+
+
+ Per page:
+
+
+
+
+
+
+
+ {userPage} / {totalPages}
+
+
+
+
+
+
+ )}
)}
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/pages/TenantManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx
index aeb8de5..dacd499 100644
--- a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx
+++ b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx
@@ -26,8 +26,7 @@ import {
createTenant,
updateTenant,
getTenantOrganisations,
- getOrganisations,
- getUsers,
+ getTenantUsers,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
@@ -36,7 +35,8 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
-import { Tenant, Organisation, TenantOrganisation, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
+import { ObfuscatedIdNumber } from '../components/ui/ObfuscatedIdNumber';
+import { Tenant, Organisation, TenantOrganisation, TenantUser, User, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
import { formatDate, getOrganisationTypeLabel, getUserEmail, registerUserEmails } from '../lib/formatters';
import { CreateUserModal } from '../components/users/CreateUserModal';
import { UserSecurityModal } from '../components/users/UserSecurityModal';
@@ -71,27 +71,21 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
staleTime: 30000,
});
- // Fetch all organisations directory (from governance cache) to resolve complete organisation details
- const { data: allOrganisations = [], isLoading: loadingAllOrgs } = useQuery({
- queryKey: ['organisations'],
- queryFn: () => getOrganisations(),
- staleTime: 60000,
- });
-
- // Fetch user directory to resolve linked user email per organisation
- const { data: usersDirectory = [] } = useQuery({
- queryKey: ['users-directory'],
- queryFn: () => getUsers(1, 200),
+ // Fetch tenant users directory to resolve linked user email per organisation
+ const { data: tenantUsers = [] } = useQuery({
+ queryKey: ['tenant-users', tenantId],
+ queryFn: () => getTenantUsers(tenantId),
staleTime: 5 * 60 * 1000,
});
React.useEffect(() => {
- if (usersDirectory && usersDirectory.length > 0) {
- registerUserEmails(usersDirectory);
+ if (tenantUsers && tenantUsers.length > 0) {
+ const users = tenantUsers.map((tu) => tu.user).filter((u): u is User => !!u);
+ registerUserEmails(users);
}
- }, [usersDirectory]);
+ }, [tenantUsers]);
- const isLoading = loadingTenantOrgs || (loadingAllOrgs && tenantOrganisations.length > 0 && allOrganisations.length === 0);
+ const isLoading = loadingTenantOrgs;
return (
@@ -171,26 +165,16 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
{tenantOrganisations.map((tOrg) => {
- const orgId = tOrg.organisationId || tOrg.organisation?.id || tOrg.id;
- // Use the organisationId to fetch/match the organisation detail
- const detailedOrg = allOrganisations.find(
- (o) => String(o.id) === String(orgId) || String(o.id) === String(tOrg.organisationId)
- );
- const org = detailedOrg || tOrg.organisation;
- const orgName = org?.name || tOrg.organisation?.name || 'Unnamed Organisation';
- const regNo = org?.registrationNo || tOrg.organisation?.registrationNo || '—';
- const orgType = org?.type ?? tOrg.organisation?.type;
- const orgEmail = org?.email || tOrg.organisation?.email || '—';
- const linkedUserId =
- org?.userId ||
- (org as any)?.UserId ||
- (org as any)?.organisationUser ||
- (org as any)?.OrganisationUser ||
- (tOrg.organisation as any)?.userId ||
- (tOrg as any)?.userId;
+ const org = tOrg.organisation;
+ const orgId = org?.id || tOrg.organisationId || tOrg.id;
+ const orgName = org?.name || 'Unnamed Organisation';
+ const regNo = org?.registrationNo || '—';
+ const orgType = org?.type ?? tOrg.status;
+ const orgEmail = org?.email || '—';
+ const linkedUserId = org?.userId;
const matchedUser = linkedUserId
- ? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(linkedUserId).toLowerCase())
+ ? tenantUsers.find((tu) => tu.user?.id && String(tu.user.id).toLowerCase() === String(linkedUserId).toLowerCase())?.user
: null;
const userEmailDisplay =
@@ -199,8 +183,8 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
(linkedUserId && String(linkedUserId).includes('@') ? String(linkedUserId) : null) ||
org?.email ||
'—';
- const status = org?.status ?? tOrg.organisation?.status ?? tOrg.status;
- const createdAt = org?.createdAt || tOrg.createdAt || tOrg.organisation?.createdAt;
+ const status = org?.status ?? tOrg.status;
+ const createdAt = org?.createdAt || tOrg.createdAt;
return (
@@ -274,21 +258,12 @@ const TenantUsersSubList: React.FC<{
onProvisionUser: (tenantId: number | string) => void;
onManageUser: (user: User) => void;
}> = ({ tenantId, tenantName, onProvisionUser, onManageUser }) => {
- const { data: usersDirectory = [], isLoading, refetch } = useQuery({
+ const { data: tenantUsers = [], isLoading, refetch } = useQuery({
queryKey: ['tenant-users', tenantId],
- queryFn: () => getUsers(1, 200),
+ queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
});
- const tenantUsers = useMemo(() => {
- return usersDirectory.filter((u) => {
- if (u.tenant_id !== undefined && u.tenant_id !== null) {
- return String(u.tenant_id) === String(tenantId);
- }
- return true;
- });
- }, [usersDirectory, tenantId]);
-
const { canAdd } = useRbac();
const canProvision = canAdd('tenant_management') || canAdd('tenant_self_service') || canAdd('settings_users');
@@ -356,24 +331,45 @@ const TenantUsersSubList: React.FC<{
| No users provisioned under Tenant #{tenantId}. |
) : (
- tenantUsers.map((u) => (
-
- | {u.email || '—'} |
- {u.id} |
- {u.idNumber || '—'} |
- {u.role || 'Standard User'} |
- |
-
-
- |
-
- ))
+ tenantUsers.map((tu) => {
+ const u = tu.user;
+ const userId = u?.id || tu.userId;
+ const email = u?.email || '—';
+ const role = u?.role || 'Standard User';
+ const idNumber = u?.idNumber;
+ const status = u?.status ?? tu.status;
+
+ return (
+
+ | {email} |
+ {userId} |
+
+
+ |
+ {role} |
+ |
+
+
+ |
+
+ );
+ })
)}
@@ -541,9 +537,24 @@ export const TenantManagementPage: React.FC = () => {
return 'Active';
};
+ // Auto-expand active tenant for tenant self-service users
+ React.useEffect(() => {
+ if (!canManageTenants && currentTenantId) {
+ setExpandedTenants((prev) => ({
+ ...prev,
+ [String(currentTenantId)]: true,
+ }));
+ }
+ }, [canManageTenants, currentTenantId]);
+
// Filter & Search
const filteredTenants = useMemo(() => {
return tenants.filter((t) => {
+ // Non-admins (tenant self-service) only see their own tenant partition boundary
+ if (!canManageTenants && currentTenantId && String(t.id) !== String(currentTenantId)) {
+ return false;
+ }
+
const s = Number(t.status ?? ActivityStatus.Active);
const matchesSearch =
!searchTerm.trim() ||
@@ -559,16 +570,16 @@ export const TenantManagementPage: React.FC = () => {
return matchesSearch && matchesStatus;
});
- }, [tenants, searchTerm, statusFilter]);
+ }, [tenants, searchTerm, statusFilter, canManageTenants, currentTenantId]);
// Sorting
const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc');
- // Metrics
- const totalCount = tenants.length;
- const activeCount = tenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
- const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length;
- const disabledCount = tenants.filter((t) => Number(t.status) === 3).length;
+ // Metrics (scoped to visible partitions)
+ const totalCount = filteredTenants.length;
+ const activeCount = filteredTenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
+ const inactiveCount = filteredTenants.filter((t) => Number(t.status) === 2).length;
+ const disabledCount = filteredTenants.filter((t) => Number(t.status) === 3).length;
// Export columns (using integer ID only, no hashid)
const exportColumns: ExportColumn [] = [
diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts
index 657e352..f7481f3 100644
--- a/PostFundManagement.ConsoleUI/src/services/api.ts
+++ b/PostFundManagement.ConsoleUI/src/services/api.ts
@@ -65,6 +65,7 @@ import {
ActivityStatus,
Tenant,
TenantOrganisation,
+ TenantUser,
CreateTenantRequest,
UpdateTenantRequest,
Beneficiary,
@@ -227,7 +228,7 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
- idNumber: payload.idNumber,
+ idNumber: payload.idNumber || null,
organisationUser: payload.organisationUser,
type: payload.type !== undefined ? Number(payload.type) : 5,
});
@@ -1632,6 +1633,26 @@ export const getTenantOrganisations = async (tenantId: string | number): Promise
}
};
+export const getTenantUsers = async (tenantId: string | number): Promise => {
+ const hashedTenantId = await toolsService.encodeId(tenantId);
+ try {
+ const res = await apiClient.get(
+ `/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/users`
+ );
+ return res.data || [];
+ } catch (err: any) {
+ if (String(tenantId) !== String(hashedTenantId)) {
+ try {
+ const fallbackRes = await apiClient.get(
+ `/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/users`
+ );
+ return fallbackRes.data || [];
+ } catch {}
+ }
+ throw err;
+ }
+};
+
// ==========================================
// 10. Projects: Beneficiaries & Outcomes APIs
// ==========================================
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/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts
index abd6786..f870d29 100644
--- a/PostFundManagement.ConsoleUI/src/types.ts
+++ b/PostFundManagement.ConsoleUI/src/types.ts
@@ -372,7 +372,7 @@ export interface CreateOrganisationRequest {
name: string;
registrationNo: string;
email: string;
- idNumber: string;
+ idNumber?: string | null;
organisationUser: string;
type: OrganisationType;
}
@@ -382,7 +382,7 @@ export interface UpdateOrganisationRequest {
name: string;
registrationNo: string;
email: string;
- idNumber: string;
+ idNumber?: string | null;
organisationUser: string;
type: OrganisationType;
status?: ActivityStatus;
@@ -400,6 +400,18 @@ export interface TenantOrganisation {
creator?: User | null;
}
+export interface TenantUser {
+ id: number | string;
+ tenantId: number | string;
+ userId: string;
+ status: ActivityStatus;
+ createdAt: string;
+ createdBy: string;
+ tenant?: Tenant | null;
+ user?: User | null;
+ creator?: User | null;
+}
+
export interface Portfolio {
id: number | string;
tenantId?: number | string;
diff --git a/PostFundManagement.ConsoleUI/yarn.lock b/PostFundManagement.ConsoleUI/yarn.lock
index 96bcd86..925672d 100644
--- a/PostFundManagement.ConsoleUI/yarn.lock
+++ b/PostFundManagement.ConsoleUI/yarn.lock
@@ -928,6 +928,18 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb"
integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==
+"@types/react-dom@^19.3.0":
+ version "19.3.0"
+ resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.3.0.tgz#90294a9266efd928a5266f2110d60d5dba5015d2"
+ integrity sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==
+
+"@types/react@^19.3.0":
+ version "19.3.0"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-19.3.0.tgz#3b3ce8d8c1627fb5ccd4dadd57ef445c5a153b77"
+ integrity sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==
+ dependencies:
+ csstype "^3.2.2"
+
"@types/retry@0.12.0":
version "0.12.0"
resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
@@ -1198,6 +1210,11 @@ css-line-break@^2.1.0:
dependencies:
utrie "^1.0.2"
+csstype@^3.2.2:
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
+ integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
data-uri-to-buffer@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
diff --git a/PostFundManagement.Domain/Entities/TenantOrganisation.cs b/PostFundManagement.Domain/Entities/TenantOrganisation.cs
index bf3cc15..d4f4269 100644
--- a/PostFundManagement.Domain/Entities/TenantOrganisation.cs
+++ b/PostFundManagement.Domain/Entities/TenantOrganisation.cs
@@ -1,11 +1,11 @@
-namespace PostFundManagement.Domain.Entities;
+namespace PostFundManagement.Domain.Entities;
[EntityTypeConfiguration]
public class TenantOrganisation : Models.TenantOrganisation
{
public virtual Tenant? Tenant { get; set; }
- public virtual Organisation? Organisation { get; set; }
+ public new virtual Organisation? Organisation { get; set; }
public virtual User? Creator { get; set; }
}
diff --git a/PostFundManagement.Domain/Entities/TenantUser.cs b/PostFundManagement.Domain/Entities/TenantUser.cs
index bac1337..11d8d6b 100644
--- a/PostFundManagement.Domain/Entities/TenantUser.cs
+++ b/PostFundManagement.Domain/Entities/TenantUser.cs
@@ -1,4 +1,4 @@
-namespace PostFundManagement.Domain.Entities;
+namespace PostFundManagement.Domain.Entities;
[EntityTypeConfiguration]
public class TenantUser : Models.TenantUser
@@ -9,7 +9,7 @@ public class TenantUser : Models.TenantUser
public virtual User? Creator { get; set; }
[ForeignKey(nameof(UserId))]
- public virtual User? User { get; set; }
+ public new virtual User? User { get; set; }
}
public sealed class TenantUserConfiguration : IEntityTypeConfiguration
diff --git a/PostFundManagement.Domain/Extensions/Mappers.cs b/PostFundManagement.Domain/Extensions/Mappers.cs
index ab850bc..904224d 100644
--- a/PostFundManagement.Domain/Extensions/Mappers.cs
+++ b/PostFundManagement.Domain/Extensions/Mappers.cs
@@ -11,7 +11,8 @@ public static class Mappers
CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy,
Status = entity.Status,
- UserId = entity.UserId
+ UserId = entity.UserId,
+ User = entity.User?.Map()
};
public static TenantOrganisation Map(this Entities.TenantOrganisation entity) => new()
@@ -21,7 +22,8 @@ public static class Mappers
CreatedAt = entity.CreatedAt,
CreatedBy = entity.CreatedBy,
OrganisationId = entity.OrganisationId,
- TenantId = entity.TenantId
+ TenantId = entity.TenantId,
+ Organisation = entity.Organisation?.Map()
};
public static Tenant Map(this Entities.Tenant entity) => new()
diff --git a/PostFundManagement.Domain/Models/TenantOrganisation.cs b/PostFundManagement.Domain/Models/TenantOrganisation.cs
index 5e1e17f..ab10515 100644
--- a/PostFundManagement.Domain/Models/TenantOrganisation.cs
+++ b/PostFundManagement.Domain/Models/TenantOrganisation.cs
@@ -1,4 +1,4 @@
-namespace PostFundManagement.Domain.Models;
+namespace PostFundManagement.Domain.Models;
public class TenantOrganisation
{
@@ -13,4 +13,6 @@ public class TenantOrganisation
public long OrganisationId { get; set; }
public ActivityStatus Status { get; set; }
+
+ public Organisation? Organisation { get; set; }
}
diff --git a/PostFundManagement.Domain/Models/TenantUser.cs b/PostFundManagement.Domain/Models/TenantUser.cs
index c9d5911..464a210 100644
--- a/PostFundManagement.Domain/Models/TenantUser.cs
+++ b/PostFundManagement.Domain/Models/TenantUser.cs
@@ -1,4 +1,4 @@
-namespace PostFundManagement.Domain.Models;
+namespace PostFundManagement.Domain.Models;
public class TenantUser
{
@@ -13,4 +13,6 @@ public class TenantUser
public Guid UserId { get; set; }
public ActivityStatus Status { get; set; }
+
+ public User? User { get; set; }
}
diff --git a/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs b/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs
index 082b0a4..029af00 100644
--- a/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs
+++ b/PostFundManagement.Infrastructure/Database/Services/TenantContextService.cs
@@ -1,4 +1,4 @@
-using PostFundManagement.Domain.Abstractions;
+using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Infrastructure.Database.Services;
@@ -17,18 +17,18 @@ public sealed class TenantContextService : ITenantContextService
public TenantContextService(IHttpContextAccessor httpContextAccessor)
{
var user = httpContextAccessor.HttpContext?.User;
-
- IsAdmin = user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true;
- ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
-
- ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
-
- HasTenantFilter = !IsAdmin;
var tenantIdClaimVal = user?.FindFirst("tenant_id")?.Value ?? user?.FindFirst("TenantId")?.Value;
TenantId = long.TryParse(tenantIdClaimVal, out var id) ? id : 0L;
var tenantNameClaimVal = user?.FindFirst("tenant")?.Value ?? user?.FindFirst("Tenant")?.Value;
TenantName = !string.IsNullOrWhiteSpace(tenantNameClaimVal) ? tenantNameClaimVal : null;
+
+ IsAdmin = (user?.IsInRole("Admin") == true || user?.HasClaim("role", "Admin") == true) && TenantId == 1;
+ ByPassTenantQueryFilter = user?.IsInRole("Partner") == true || user?.HasClaim("role", "Partner") == true;
+
+ ByPassTenantQueryFilter = ByPassTenantQueryFilter || IsAdmin;
+
+ HasTenantFilter = !IsAdmin;
}
-}
\ No newline at end of file
+}
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
|