From 28a821ae914ae1a6e058cbe45b785bdcb82b6d3e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 19 Oct 2026 10:54:11 +0200 Subject: [PATCH 1/4] Added GEMINI.md file --- GEMINI.md | 1099 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1099 insertions(+) create mode 100644 GEMINI.md diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..c2e85e3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,1099 @@ +# PostFundManagement (PFM) Platform — Engineering & Architecture Guide (GEMINI.md) + +> **Document Context**: This document serves as the primary system specification, technical architectural guide, and development roadmap for the **Post-Fund Management, Performance & Impact Management Platform (PFM)**. It is directly derived from the **PFM System Requirements Specification (SRS) v2.0** (authored by S. Phiri, 13 August 2026) and reflects the actual technical implementation and project layout in this repository. +> +> **Role for Gemini / Antigravity**: Use this file as your authoritative blueprint when extending, debugging, testing, or implementing any features, endpoints, domain models, or UI components across the PFM solution. + +--- + +## 1. Executive Summary & Product Perspective + +The **Post-Fund Management (PFM) Platform** is an enterprise-grade multi-tenant software system designed for organisations that administer and govern **Innovation, Development, NGO/NPO, Donor, Grant, Blended-Finance, and Impact-Funding** portfolios. + +### 1.1 Scope Boundary +* **Upstream Boundary (Out of Scope)**: Call for applications, proposal intake, eligibility screening, technical/financial review, due diligence, and approval committee deliberation. Upstream systems hand off approved awards into PFM via REST APIs, webhooks, or batch imports. +* **Controlled Lifecycle (In Scope)**: The PFM platform lifecycle strictly begins at **APPROVED FUNDING AWARD**. Recording an approved award triggers the automated Contract/Agreement generation workflow. From that entry point, PFM governs: + $$\text{Award Intake} \longrightarrow \text{Contracting} \longrightarrow \text{Onboarding} \longrightarrow \text{Implementation / Milestones} \longrightarrow \text{Disbursement Control} \longrightarrow \text{M\&E / Risk / Compliance} \longrightarrow \text{Close-Out} \longrightarrow \text{Post-Fund Monitoring} \longrightarrow \text{Portfolio Impact Reporting}$$ + +``` + ┌─────────────────────────────────────────────────────────┐ + │ EXTERNAL / UPSTREAM (Out of Scope) │ + │ Call for Apps ─> Application ─> Assessment ─> Approval │ + └────────────────────────────┬────────────────────────────┘ + │ (Handover via API / Import) +════════════════════════════════▼══════════════════════════════════ SYSTEM SCOPE BEGINS + ┌─────────────────────────────────────────────────────────┐ + │ PFM CONTROLLED LIFECYCLE (In Scope) │ + │ │ + │ [M01] Award Intake & Registration │ + │ │ │ + │ ▼ │ + │ [M02] Contract & Agreement Management │ + │ │ │ + │ ▼ │ + │ [M03] Recipient Onboarding (Banking & KYC Gate) │ + │ │ │ + │ ├──────────────────────────┐ │ + │ ▼ ▼ │ + │ [M04] Project Management [M05] Budget & Finance │ + │ │ │ │ + │ ▼ ▼ │ + │ [M07] Milestones & Evidence ──> [M06] Disbursement │ + │ │ │ + │ ├─────────────────────────────────────┐ │ + │ ▼ ▼ │ + │ [M08] M&E / MEAL Indicators [M09] Risk Mgmt │ + │ [M10] Compliance & [M11] ESG [M16] Issues │ + │ [M12] Innovation / TRL [M17] Site Visits│ + │ [M13] Beneficiaries (POPIA) [M15] Change Ctrl│ + │ │ │ │ + │ └──────────────────┬──────────────────┘ │ + │ ▼ │ + │ [M18] Formal Project Close-Out │ + │ │ │ + │ ▼ │ + │ [M19] Post-Fund Monitoring (6 to 60 Months) │ + │ │ │ + │ ▼ │ + │ [M20 / M22] Executive Portfolio Dashboards & Reports │ + └─────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Tech Stack Across Projects in the Repository + +The solution is architected using **Clean Architecture** and **Domain-Driven Design (DDD)** on **.NET 10.0 (C# 13)** for the backend, paired with modern **React 19 / TypeScript / Vite** frontend applications. + +``` +PostFundManagement/ +├── PostFundManagement.Domain/ # Core entities, enums, value objects, domain logic +├── PostFundManagement.Infrastructure/ # EF Core 10, PostgreSQL (Npgsql), Migrations, Data Protection +├── PostFundManagement.Application/ # CQRS Handlers, Mediator, DTOs, Validations, S3, Email, Quartz +├── PostFundManagement.Api/ # Minimal APIs / Carter endpoints, Auth, Swagger/Scalar, Versioning +├── PostFundManagement.Tests/ # Unit & Integration test suite (xUnit, Coverlet) +├── PostFundManagement.ConsoleUI/ # Internal Staff & Admin Web Portal (React 19 + Vite 6 + Tailwind 4) +├── PostFundManagement.PartnerUI/ # External Grantee / Partner Portal (React 19 + Vite 8 + Tailwind 4) +└── docker-compose.yml # Container orchestration for API and Frontends +``` + +### 2.1 Backend Projects (.NET 10.0 / C# 13) + +| Project | Target Framework | Core Libraries & Packages | Architectural Purpose | +| :--- | :--- | :--- | :--- | +| **`PostFundManagement.Domain`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Humanizer` 3.0.10, `Hashids.net` 1.7.0, `OpenTelemetry` 1.18.0, `Quartz` 4.1.0, `FluentResults` 4.0.0, `Mediator.Abstractions` 3.0.2, `AWSSDK.S3` 4.0.103.3, `MailKit`/`MimeKit` 4.18.0 | Contains domain entities, aggregate roots, value objects, domain enums, constant definitions, and interfaces. Clean of presentation concerns. | +| **`PostFundManagement.Infrastructure`** | `net10.0` | `Microsoft.EntityFrameworkCore` 10.0.12, `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.3, `Duende.AccessTokenManagement` 4.2.0, EF Core Tools & Design | Houses `PostFundManagementDbContext`, EF Core fluent configurations, entity-to-table mappings, PostgreSQL schema definitions, multitenancy filters, and migrations. | +| **`PostFundManagement.Application`** | `net10.0` | `Mediator.SourceGenerator` / `Mediator.Abstractions` 3.0.2, `FluentResults` 4.0.0, `Microsoft.Extensions.Caching.Hybrid` 10.10.0, `Microsoft.Extensions.Caching.StackExchangeRedis` 10.0.12, `Refit` 15.2.0, `Duende.AccessTokenManagement.OpenIdConnect` 4.2.0, `Quartz` 4.1.0, `AWSSDK.S3` | Implements CQRS command/query handlers, domain validators, S3 object storage clients (Garage S3), background jobs (Quartz), notification dispatchers, and business logic. | +| **`PostFundManagement.Api`** | `net10.0` | `Microsoft.AspNetCore.OpenApi` 10.0.12, `Scalar.AspNetCore` 2.17.4, `Asp.Versioning.Http` 10.2.3, `IdentityModel.AspNetCore` 4.3.0, `Microsoft.AspNetCore.Authentication.JwtBearer` 10.0.12, `AspNetCore.HealthChecks.NpgSql` 9.0.0, `AspNetCore.HealthChecks.UI` | Minimal API endpoint definitions divided by business slices (`Grants`, `Projects`, `Execution`, `Governance`, `Evidence`, `Communications`, `Users`). Handles routing, auth policies, error problem details, and OpenAPI metadata. | +| **`PostFundManagement.Tests`** | `net10.0` | `xunit` 2.9.3, `xunit.runner.visualstudio` 3.1.4, `Microsoft.NET.Test.Sdk` 17.14.1, `coverlet.collector` 6.0.4 | Unit and integration test coverage for domain rules, CQRS handlers, financial calculation models, and gate condition validations. | + +### 2.2 Frontend Projects (React 19 / TypeScript) + +#### **`PostFundManagement.ConsoleUI`** (Internal Staff / Portfolio Administration Portal) +* **Runtime & Bundler**: React 19.0.1, TypeScript 5.8.2, Vite 6.2.3 +* **Styling**: Tailwind CSS v4.1.14 (`@tailwindcss/vite`, `@tailwindcss/postcss`) +* **Routing**: React Router DOM v7.18.3 +* **State & Server Querying**: `@tanstack/react-query` v5.102.8, Axios 1.20.0 +* **Forms & Validation**: `react-hook-form` 7.87.0, `zod` 3.25.76, `@hookform/resolvers` +* **Authentication**: `oidc-client-ts` 3.5.0 (Duende IdentityServer / STS PKCE authentication) +* **Animation & Icons**: `motion` (Framer Motion) 12.23.24, `lucide-react` 0.546.0 +* **Exports & Utilities**: `jspdf` 4.2.1, `jspdf-autotable` 5.0.8, `xlsx` 0.18.5, `hashids` 2.3.0 +* **AI Capabilities**: `@google/genai` 2.4.0 (for intelligent portfolio insights and drafting) +* **Target Users**: Funding Administrators, Programme Managers, Finance Officers, M&E Officers, Risk Managers, System Administrators, Executive Leadership. + +#### **`PostFundManagement.PartnerUI`** (External Recipient / Grantee Portal) +* **Runtime & Bundler**: React 19.0.1, TypeScript 7.0.2, Vite 8.3.0 +* **Styling & Effects**: Tailwind CSS v4.3.3, `motion` 12.23.24, `ogl` 1.0.11 (WebGL visual elements like MoltenMetal, BorderGlow, SpecularButton) +* **Routing**: React Router DOM v7.18.4 +* **State & Server Querying**: `@tanstack/react-query` v5.104.0, Axios 1.20.0 +* **Forms & Validation**: `react-hook-form` 7.89.0, `zod` 4.6.5 +* **Authentication**: `oidc-client-ts` 3.5.0 +* **Exports & Utilities**: `jspdf`, `xlsx`, `hashids` +* **Target Users**: Grantee / Partner Project Directors, Financial Controllers, Field Officers submitting evidence and disbursement drawdown requests. + +### 2.3 Infrastructure, Storage & Security Topology +* **Database**: PostgreSQL 14+ (Separate connection strings for primary transactional store `pfm` and scheduler store `pfm-scheduler`). +* **Object / Evidence Store**: S3-compatible storage (e.g., Garage S3 / AWS S3) with pre-signed upload URLs and CDN acceleration. +* **Cache**: Redis (`StackExchange.Redis` + ASP.NET HybridCache) for dashboard KPIs and taxonomy lookup caching. +* **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`). +* **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors. + +--- + +## 3. Master Business Rules (BR-001 to BR-010) + +The system enforces 10 cardinal business rules across all modules and APIs: + +| Rule ID | Rule Name | Specification & Enforcement Logic | MoSCoW | +| :--- | :--- | :--- | :--- | +| **BR-001** | **Approval is Entry Point** | No contract, project, or budget may be created without a verified, approved funding award reference. | **Must Have** | +| **BR-002** | **Contract Initiated Automatically** | Capturing and activating an approved award record automatically transitions into and initializes the Contract/Agreement workflow. | **Must Have** | +| **BR-003** | **No Uncontrolled Disbursement** | No payment or disbursement may be approved or released unless all applicable conditions precedent and milestone evidence validations are formally satisfied. | **Must Have** | +| **BR-004** | **Evidence-Based Performance** | Reported performance on milestones and indicators must be substantiated by mandatory uploaded evidence documents/files before approval. | **Must Have** | +| **BR-005** | **Single Source of Truth** | A data entity (recipient org, award amount, bank details, baseline) is captured once and referenced immutably across the full lifecycle. | **Must Have** | +| **BR-006** | **Configurable Funding Instruments** | Funding rules, milestones, payment schedules, KPIs, reporting frequencies, and risk thresholds must not be hardcoded; they vary by funding instrument type. | **Should Have** | +| **BR-007** | **Full Auditability** | Every material decision, status transition, payment approval, budget adjustment, and evidence submission writes an immutable, user-attributed log entry. | **Must Have** | +| **BR-008** | **Results Hierarchy** | Performance indicators strictly align to the causal chain: $\text{Input} \longrightarrow \text{Activity} \longrightarrow \text{Output} \longrightarrow \text{Outcome} \longrightarrow \text{Impact}$. | **Must Have** | +| **BR-009** | **Indicator Integrity** | Every measurable metric follows the evaluation sequence: $\text{Baseline} \longrightarrow \text{Target} \longrightarrow \text{Actual} \longrightarrow \text{Variance} \longrightarrow \text{Supporting Evidence}$. | **Must Have** | +| **BR-010** | **Automated Portfolio Roll-Up** | Activity and project data automatically roll up to Programme $\longrightarrow$ Portfolio $\longrightarrow$ Institution $\longrightarrow$ National/Donor reporting tiers without manual consolidation. | **Must Have** | + +--- + +## 4. System Architecture & Data Models + +### 4.1 Core Domain Entities & Relationships + +```mermaid +erDiagram + Organisation ||--o{ Award : "receives" + FundingProgramme ||--o{ Award : "governs" + Award ||--|| Contract : "initiates" + Award ||--|| Project : "funds" + Project ||--o{ Budget : "allocates" + Project ||--o{ Milestone : "tracks" + Project ||--o{ Risk : "identifies" + Project ||--o{ Indicator : "measures" + Project ||--o{ BeneficiaryGroup : "reaches" + Milestone ||--o{ Disbursement : "gates" + Milestone ||--o{ Evidence : "requires" + Indicator ||--o{ Evidence : "substantiated_by" + FundingProgramme ||--o{ PortfolioSnapshot : "aggregates" +``` + +### 4.2 Key System Enums & Taxonomies + +* **`IndustrySector`**: ICT, Agriculture, Mining, Manufacturing, Energy, Healthcare, Education, Financial Services, Tourism, Biotechnology. +* **`ProjectType`**: Research & Development (R&D), Technology Commercialisation, Infrastructure, Capacity Building, Social Impact, Community Development. +* **`EvidenceType`**: Invoice, Bank Statement, Progress Report, Geotagged Photograph, Attendance Register, Research Output, Prototype Demonstration, Certification, Patent Filing, Audited Financial Statement, Beneficiary Schedule. +* **`FundingInstrument`**: + 1. *Grant*: Award $\to$ Milestone $\to$ Disbursement $\to$ Reporting. + 2. *Development Finance*: Investment $\to$ Drawdown $\to$ Conditions $\to$ Repayment / Performance. + 3. *Impact Investment*: Investment $\to$ KPI $\to$ Impact $\to$ Financial/Social Return. + 4. *Blended Finance*: Multi-tranche (Grant + Debt + Equity + Technical Assistance). + 5. *NGO Programme*: Grant $\to$ Activities $\to$ Beneficiaries $\to$ Outcomes $\to$ Impact. + 6. *Innovation Funding*: Grant $\to$ TRL Progression $\to$ Prototype $\to$ Validation $\to$ Commercialisation. + +--- + +## 5. Functional Requirements Breakdown (Modules M01 – M25) + +The SRS v2.0 establishes **25 functional modules** with **304 prioritized requirements** and Given/When/Then acceptance criteria: + +### **Module M01 — Award Intake & Funding Registration** +* **Purpose**: Capture and validate the official award record following external approval committee sanction. +* **Priority**: 17 Must Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M01-001` to `007`: System shall capture mandatory fields: External approval reference number, funding amount ($> 0$), funding period (start & end dates), programme, instrument, recipient organisation, and project title. + * `FR-M01-016` (Duplicate Prevention): Given an award registration in progress, when the external reference number matches an existing record, then the system blocks creation and presents the existing award. + * `FR-M01-017` (Contract Initiation Trigger): When an award is successfully registered, the system automatically creates the corresponding `Contract` record in `DRAFT` status (BR-002). +* **Module Acceptance Gates**: Award cannot be activated without external reference, recipient in master data, amount $> 0$, and valid date boundaries. + +### **Module M02 — Contract & Agreement Management** +* **Purpose**: Convert approved awards into legally binding, monitorable contracts with version control. +* **Workflow**: `DRAFT` $\longrightarrow$ `UNDER REVIEW` $\longrightarrow$ `EXECUTED` $\longrightarrow$ `AMENDED` (versioned) $\longrightarrow$ `EXPIRED / TERMINATED`. +* **Priority**: 22 Must Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M02-001` to `012`: Support contract templates, configurable legal clauses, signing parties, effective & expiry dates, contractual obligations, payment conditions, and digital/scanned signatures. + * `FR-M02-020`: The executed agreement is locked as the authoritative legal version. Subsequent amendments (`FR-M02-021`) generate incremented versions (v1.1, v2.0) with full diff logs. +* **Module Acceptance Gates**: A contract cannot transition to `EXECUTED` unless approved award exists, authorised parties are identified, agreement version is set, required signatures are attached, and effective date exists. + +### **Module M03 — Recipient Onboarding** +* **Purpose**: Verify recipient organization credentials, key personnel, KYC compliance, and banking details before funds can be released. +* **Priority**: 13 Must Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M03-001` to `006`: Establish organization profile, key personnel, verified banking details, tax/statutory compliance documents, project team assignments, and configurable onboarding checklists. + * `FR-M03-012` (First Disbursement Gate): Onboarding verification is a mandatory prerequisite for the first disbursement. If banking details are unverified or compliance docs are missing, disbursement approvals are strictly blocked (AT-03). + * `FR-M03-013`: Recipient profiles and verified banking details can be reused across subsequent awards (BR-005). + +### **Module M04 — Project & Programme Management** +* **Purpose**: Plan, schedule, and track implementation at the work breakdown structure (WBS), activity, and task levels. +* **Workflow**: `SET-UP` $\longrightarrow$ `READY FOR IMPLEMENTATION` $\longrightarrow$ `ACTIVE` $\longrightarrow$ `AT RISK` $\longrightarrow$ `SUSPENDED` $\longrightarrow$ `COMPLETED` $\longrightarrow$ `CLOSED` $\longrightarrow$ `POST-FUND` $\longrightarrow$ `ARCHIVED`. +* **Priority**: 21 Must Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M04-001` to `014`: Work breakdown structure, activities, tasks, responsible assignees, activity dependencies, deliverable links, and timelines. + * `FR-M04-015`: Automatically monitor planned vs actual completion. Highlight delayed activities (`FR-M04-017`). + * `FR-M04-018`: Project progress percentage is calculated automatically from verified milestone completions. + * `FR-M04-021`: Project status and metrics automatically roll up to programme level (BR-010). + +### **Module M05 — Budget & Financial Management** +* **Purpose**: Maintain the approved project budget, commitments, actual expenditure, variance, and leveraged co-funding. +* **Priority**: 18 Must Have, 1 Should Have +* **Core Financial Calculations**: + $$\text{Budget Utilisation (\%)} = \frac{\text{Actual Expenditure}}{\text{Approved Budget}} \times 100$$ + $$\text{Variance} = \text{Approved Budget} - (\text{Actual Expenditure} + \text{Committed Expenditure})$$ + $$\text{Leverage Ratio} = \frac{\text{Additional External Capital Leveraged}}{\text{Total PFM Funding Award}}$$ +* **Key Requirements & Acceptance Criteria**: + * `FR-M05-001` to `013`: Maintain multi-year budgets, budget categories, commitments, forecasts, and expenditures. + * `FR-M05-011` / `AT-04`: If cumulative commitments plus expenditure exceed an approved budget line, the system blocks the transaction and demands an approved budget reallocation/revision. + * `FR-M05-017` & `018`: Track third-party co-funding and private sector co-investment leverage. + +### **Module M06 — Disbursement Management** +* **Purpose**: Control the release of grant tranches against verified milestones and satisfied conditions precedent. +* **Priority**: 14 Must Have, 1 Should Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M06-001` to `007`: Multi-tranche disbursement schedules, milestone linkage, conditions precedent validation, payment request capture, multi-stage approval workflow, payment reference recording, and bank reconciliation. + * `FR-M06-008` & `012` / `AT-05`: Payment release requires: (1) Linked milestone is in `VERIFIED` status, (2) Conditions precedent satisfied, (3) Recipient onboarding complete. Duplicate payment approval attempts on the same tranche are blocked. + +### **Module M07 — Milestone & Deliverable Management** +* **Purpose**: Track project milestones and deliverable submissions through rigorous evidence-based verification. +* **Workflow**: `PLANNED` $\longrightarrow$ `IN PROGRESS` $\longrightarrow$ `SUBMITTED` $\longrightarrow$ `UNDER REVIEW` $\longrightarrow$ `(REWORK REQUIRED | VERIFIED)` $\longrightarrow$ `PAID` $\longrightarrow$ `CLOSED`. +* **Priority**: 10 Must Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M07-001` to `005`: Define contractual milestones, associate deliverables, define mandatory evidence types, monitor due dates, and allow grantees to submit evidence. + * `FR-M07-006` & `007`: Authorised internal reviewers verify or reject submissions (with mandatory rework reasoning). + * `FR-M07-009`: Overdue milestones are automatically flagged and escalated to Programme Managers. + +### **Module M08 — Monitoring, Evaluation, Accountability & Learning (M&E / MEAL)** +* **Purpose**: Track the results chain from input to impact against baseline and target indicators. +* **Priority**: 20 Must Have, 2 Should Have +* **Indicator RAG Status Thresholds**: + * 🟢 **Green (On Track)**: $\ge 90\%$ of target + * 🟡 **Amber (At Risk)**: $70\% - 89\%$ of target + * 🔴 **Red (Off Track)**: $< 70\%$ of target +* **Key Requirements & Acceptance Criteria**: + * `FR-M08-001` & `002`: Support Theory of Change (ToC) and Logical Framework (Logframe) models. + * `FR-M08-003` to `018`: Baseline, target, actual recording with mandatory evidence attachment (`FR-M08-016` / `AT-06`). + * `FR-M08-021`: Clearly segregate output indicators (operational delivery) from outcome indicators (behavioural/economic changes). + +### **Module M09 — Risk Management** +* **Purpose**: Identify, assess, mitigate, and escalate risks across projects and portfolios. +* **Risk Categories**: Strategic, Financial, Operational, Technical, Commercial, Governance, Regulatory, Reputational, Cybersecurity, Fraud, ESG, Sustainability. +* **Priority**: 8 Must Have, 2 Should Have +* **Key Requirements & Acceptance Criteria**: + * `FR-M09-001` to `005`: Project risk registers, risk owners, Likelihood (1-5) and Impact (1-5) scoring, mitigation actions, and automated **Residual Risk** calculation. + * `FR-M09-006` / `AT-07`: High-rated risks with overdue mitigations automatically escalate to the Risk Dashboard (`D05`) and Programme Manager alerts. + * `FR-M09-008`: Dynamic portfolio-wide risk heatmaps. + +### **Module M10 — Compliance Management** +* **Purpose**: Manage statutory, contractual, financial, tax, POPIA/data protection, and safeguarding obligations. +* **Priority**: 8 Should Have +* **Key Requirements**: Obligation tracking, automatic due date reminders, overdue escalation (`AT-08`), and compliance exception registers. + +### **Module M11 — ESG & Sustainability** +* **Purpose**: Track Environmental, Social, and Governance metrics alongside post-grant organizational viability. +* **Priority**: 8 Should Have +* **Key Requirements**: Environmental indicators (carbon offset, waste, energy), Social indicators (diversity, community development), Governance ratings, and project sustainability assessments. + +### **Module M12 — Innovation & Technology (NSI Differentiator)** +* **Purpose**: Specialized tracking for South African National System of Innovation (NSI) and technology grants. +* **Priority**: 9 Should Have, 4 Could Have +* **Key Requirements**: + * Innovation Passport creation (`FR-M12-001`). + * **Technology Readiness Level (TRL 1–9)** progression tracking (Starting TRL vs Current TRL). + * Tracking prototypes, IP assets/patents (`FR-M12-006`), validation tests, technology stalls (`FR-M12-012`), commercialisation status, licensing agreements, and follow-on venture capital. + +### **Module M13 — Beneficiary & Stakeholder Management** +* **Purpose**: Register and report on direct/indirect beneficiaries for social and community development grants. +* **Priority**: 9 Could Have +* **Key Requirements**: Demographic categorization (gender, youth, disability, geography), outcome linkages, duplicate detection, and **POPIA-compliant field-level data protection and anonymization** (`FR-M13-007`). + +### **Module M14 — Evidence & Document Management** +* **Purpose**: Secure repository for all supporting files, verifications, audit sheets, and legal documents. +* **Priority**: 9 Must Have +* **Key Requirements**: + * File uploads against milestones, indicators, and change requests. + * Metadata tracking: file versioning, hash verification, MIME type restrictions, and maximum size limits. + * Storage integration via pre-signed S3 URLs. + * Immutable audit trail for all document uploads, reviews, approvals, and deletions (`FR-M14-007`). + +### **Module M15 — Change Control & Variation Management** +* **Purpose**: Governed process for adjusting project scope, budget reallocations, milestones, or timeline extensions. +* **Priority**: 8 Should Have +* **Key Requirements**: + * Formal Change Requests with impact assessments and justifications (`FR-M15-001` to `005`). + * Approval workflow. When approved, baseline updates are recorded while preserving original approved baselines for audit history (`FR-M15-008` / `AT-09`). + +### **Module M16 — Issue, Exception & Escalation** +* **Purpose**: Log, triage, assign, and resolve operational blockers during project execution. +* **Priority**: 7 Should Have +* **Key Requirements**: Issue ticketing, severity grading, owner assignment, automated escalation, linking issues to risks and milestones, and in-app requests for PM commentary. + +### **Module M17 — Site / Field Monitoring** +* **Purpose**: In-field inspection and audit visits with mobile-friendly offline support. +* **Priority**: 7 Should Have, 1 Could Have +* **Key Requirements**: Visit scheduling, digital standardized checklists, offline observation caching and sync (`NFR-010`), geotagged photo capture, and automated site visit report generation. + +### **Module M18 — Close-Out Management** +* **Purpose**: Formal project decommission upon settling all contractual, financial, and reporting obligations. +* **Priority**: 9 Should Have +* **Key Requirements**: Final technical/financial report verification, asset reconciliation, lessons learned capture, formal management closure approval, and automated handover into Post-Fund Monitoring (`FR-M18-009` / `AT-10`). + +### **Module M19 — Post-Fund Monitoring (Fundamental Value Driver)** +* **Purpose**: Track long-term economic sustainability, organizational survival, and lasting impact after grant funding has concluded. +* **Standard Follow-Up Intervals**: **6 months, 12 months, 24 months, 36 months, and 60 months**. +* **Priority**: 10 Should Have, 2 Could Have +* **Key Metrics Tracked**: Enterprise survival status, recurring revenue, net new jobs sustained, additional investment/capital raised, ongoing technology adoption, and commercial scale. + +### **Module M20 — Portfolio Management** +* **Purpose**: Executive cross-project aggregation and strategic portfolio analytics. +* **Priority**: 15 Must Have +* **Key Visualizations & Analytics**: Funding by sector, geography (provinces/municipalities), organisation type, TRL stage, and funding programme. Real-time lists of high-risk and underperforming projects. + +### **Module M21 — Knowledge & Learning** +* **Purpose**: Institutional memory capturing what works and what fails to improve future programme design. +* **Priority**: 8 Could Have +* **Key Requirements**: Lessons learned catalog, failure taxonomy classification, case study generation, and evaluation recommendations. + +### **Module M22 — Reporting & Business Intelligence** +* **Purpose**: High-fidelity dashboards and role-tailored reporting exports (PDF, Excel). +* **Priority**: 12 Must Have, 1 Should Have +* **Key Deliverable**: **Executive Portfolio Dashboard (D01)** rendering 6 headline KPIs: + 1. Total Portfolio Value ($ZAR$) + 2. Active Awards Count + 3. Total Disbursed Capital ($ZAR$) + 4. Overall % On Track + 5. Count of Projects At Risk + 6. Total Beneficiaries Reached + * Accompanying panels: Funding by Sector donut chart, Portfolio Risk Heatmap, Funding-to-Impact trendline, and an actionable "Projects Requiring Attention" table. + +### **Module M23 — Administration & Configuration** +* **Purpose**: System setup, multi-tenant provisioning, role definitions, and taxonomy governance. +* **Priority**: 3 Should Have, 8 Could Have +* **Key Requirements**: Tenant isolation, user onboarding, role-permission matrix configuration (Create, Read, Update, Approve, Verify, Export - CRUAVX), and funding instrument parameterization. + +### **Module M24 — Integration & API Management** +* **Purpose**: Secure interfaces for upstream intake, payment gateways, and external registries. +* **Priority**: 2 Should Have, 3 Could Have, 3 Won't Have (Deep ERP, GIS map layer, and external research DBs deferred). +* **Key Requirements**: REST APIs, inbound webhook handlers, API transaction logging, and retry resilience (Polly). + +### **Module M25 — Audit & Assurance** +* **Purpose**: Full traceability and compliance verification for internal and external auditors. +* **Priority**: 9 Should Have +* **Key Requirements**: Queryable immutable audit log (`AT-12`), history of who approved every payment, contract version history, and full audit trails tracing any portfolio metric back to source evidence. + +--- + +## 6. Non-Functional Requirements (NFR-001 – NFR-010) + +| ID | Category | Requirement & Acceptance Target | MoSCoW | +| :--- | :--- | :--- | :--- | +| **NFR-001** | **Performance (Pages)** | Standard transactional pages render within $\le 3$ seconds at the 95th percentile under normal load. | **Must Have** | +| **NFR-002** | **Performance (Dashboards)**| Standard dashboards render in $\le 5$ seconds; heavy reports process asynchronously with user notifications. | **Must Have** | +| **NFR-003** | **Availability** | Production uptime $\ge 99.5\%$, with higher availability targets for disbursement/payment services. | **Must Have** | +| **NFR-004** | **Security & Access** | Multi-factor authentication (MFA), TLS 1.3 in transit, AES-256 at rest, RBAC/ABAC enforcement, secure API tokens. | **Must Have** | +| **NFR-005** | **Scalability** | Capable of supporting thousands of organisations, tens of thousands of projects, and millions of evidence records. | **Should Have** | +| **NFR-006** | **Disaster Recovery** | Recovery Point Objective ($\text{RPO}$) $\le 1$ hour; Recovery Time Objective ($\text{RTO}$) $\le 4$ hours. | **Should Have** | +| **NFR-007** | **Multi-Tenancy Isolation** | Logical tenant isolation enforced across all database queries. Data from Tenant A never leaks to Tenant B. | **Must Have** | +| **NFR-008** | **Audit Trail Integrity** | Tamper-evident, timestamped, append-only audit trail for all state changes and financial approvals. | **Must Have** | +| **NFR-009** | **Configurability** | Business rules, payment triggers, and KPI libraries configurable by admins without code changes or redeployments. | **Should Have** | +| **NFR-010** | **Mobile Offline Tolerance** | Site visit and field data collection works seamlessly without connectivity, synchronizing once back online. | **Should Have** | + +--- + +## 7. Cross-Module Acceptance Test Plan (AT-01 – AT-14) + +These 14 end-to-end integration test scenarios must be verified before production readiness: + +1. **AT-01 (Award to Contract)**: Valid award creates award record, rejects duplicate reference numbers, and auto-initiates Contract workflow. Attempts to disburse against an award without an executed contract are blocked. +2. **AT-02 (Contract Execution)**: Executing a contract with all signatures locks it as authoritative and activates downstream budget and milestone modules. Missing signatures prevent transition to `EXECUTED`. +3. **AT-03 (Onboarding Gate)**: Attempting to process a first disbursement when recipient banking details are unverified blocks the payment until onboarding is complete. +4. **AT-04 (Budget Control)**: Attempting to commit expenditure that exceeds the approved budget line is blocked until an approved budget revision exists. +5. **AT-05 (Milestone-Linked Disbursement)**: Payment requests are only eligible for approval when the linked milestone is in `VERIFIED` status. Duplicate approvals on the same tranche are blocked. +6. **AT-06 (Evidence & Indicators)**: Submitting an indicator actual value without required supporting evidence is blocked. Recording actual values automatically computes variance and RAG status. +7. **AT-07 (Risk Escalation)**: A High-rated risk with an overdue mitigation date is automatically escalated to the Risk Dashboard and PM alert queue. +8. **AT-08 (Compliance Tracking)**: A compliance obligation passing its due date without submission automatically marks overdue and triggers escalation. +9. **AT-09 (Change Control)**: A budget reallocation exceeding thresholds updates the approved baseline only after formal approval, preserving original baselines. +10. **AT-10 (Close-Out & Handover)**: Project closure is blocked if unresolved risks or missing reports remain. Upon closure confirmation, the project is enrolled in Post-Fund Monitoring (6/12/24/36/60-month schedule). +11. **AT-11 (Portfolio Roll-Up)**: Indicator actuals verified at project level immediately aggregate into programme and portfolio totals. +12. **AT-12 (Audit Trail Integrity)**: Any approval, payment, or budget edit creates an immutable, user-attributed audit entry that cannot be altered even by admins. +13. **AT-13 (Role-Based Access)**: Users without payment-approval roles attempting to execute an approval are denied and the attempt is logged. +14. **AT-14 (Multi-Tenancy Isolation)**: Users querying awards, projects, or budgets only receive data matching their authenticated `TenantId`. + +--- + +## 8. Current Implementation Status & Gap Analysis + +### 8.1 Completed Components (Existing in Codebase) +* **Domain Layer**: + * Core entities defined: `Award`, `Contract`, `Project`, `Programme`, `Portfolio`, `Budget`, `Milestone`, `Activity`, `Indicator`, `Outcome`, `Beneficiary`, `Disbursement`, `Risk`, `Issue`, `ComplianceItem`, `SiteVisit`, `ChangeRequest`, `Evidence`, `AuditLog`, `Tenant`, `User`, `Rule`, `Instrument`. + * Enums for sectors, project types, evidence types, and notification channels. +* **Infrastructure Layer**: + * PostgreSQL EF Core DbContext with model configurations and initial migrations. + * AWS S3 SDK integration for document uploads. +* **API Endpoints**: + * Endpoints grouped by slices: Grants (`CreateAward`, `CreateContract`, `ExecuteContract`, `CreateBudget`), Projects (`CreateProject`, `CreateMilestone`, `CreateActivity`, `UpdateProjectStatus`), Execution (`CreateDisbursement`, `ApproveDisbursement`, `CreateRisk`), Evidence (`CreateEvidence`, `CreateIndicator`), Governance (`CreateTenant`, `CreateOrganisation`). +* **UI Skeletons**: + * `ConsoleUI`: Routing and layout for Dashboard, Projects, Grants, Beneficiaries, Governance, Monitoring, Settings, SiteVisits. + * `PartnerUI`: Grantees landing, basic project card display, and evidence upload modal shell. + +### 8.2 Remaining Work & Implementation Priorities + +To bring the project to full compliance with SRS v2.0, the following implementation tasks are prioritized: + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RELEASE 1: MVP CORE THREAD (Priority 1) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 1. Complete M01 Award Intake validation (duplicate ref check, auto-contract) │ +│ 2. Enforce M02 Contract locks & signature verification gates │ +│ 3. Build M03 Recipient Onboarding gate blocking 1st disbursement (AT-03) │ +│ 4. Implement M05 Budget overrun prevention (AT-04) & formula calculations │ +│ 5. Implement M06 Disbursement conditions precedent & milestone gating(AT-05)│ +│ 6. Complete M07/M14 Milestone evidence review queue & verification logic │ +│ 7. Complete M08 Indicator RAG threshold engine (Green/Amber/Red) (AT-06) │ +│ 8. Connect M20/M22 Executive Portfolio Dashboard D01 with live KPIs │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RELEASE 2: GOVERNANCE & CONTROL (Priority 2) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 1. M09 Risk likelihood/impact matrix, residual risk, auto-escalation (AT-07)│ +│ 2. M10 Compliance tracking & automated due date escalation (AT-08) │ +│ 3. M15 Change request lifecycle & baseline history preservation (AT-09) │ +│ 4. M16 Operational issue tracker with PM commentary requests │ +│ 5. M17 Mobile/offline site visit inspection checklists (NFR-010) │ +│ 6. M18 Formal project close-out checklist gate (AT-10) │ +│ 7. M19 Post-Fund Monitoring schedule generator (6/12/24/36/60 mo intervals) │ +│ 8. M25 Audit log viewer with end-to-end evidence traceability (AT-12) │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ RELEASE 3: DIFFERENTIATORS & SPECIALIZED DOMAINS (Priority 3) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 1. M12 Innovation Passport & TRL (1–9) tracker (prototypes, patents) │ +│ 2. M13 Beneficiary outcomes with POPIA-compliant personal data masking │ +│ 3. M11 ESG indicators and post-funding sustainability scores │ +│ 4. M21 Knowledge repository, failure categorization & case studies │ +│ 5. M23 Dynamic funding instrument configurator (Grant, DFI, Blended, NSI) │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 9. Developer Guidelines & Coding Conventions + +When adding or modifying code in this codebase, strictly follow these standards: + +1. **Architecture & CQRS**: + * API endpoints in `PostFundManagement.Api/Endpoints/` must remain lightweight. They parse HTTP requests, dispatch commands/queries via `IMediator`, and map `Result` from `FluentResults` into appropriate HTTP responses (`Results.Ok`, `Results.BadRequest`, `Results.Problem`). + * Business logic belongs in `PostFundManagement.Application` command and query handlers. + * Entities in `PostFundManagement.Domain` protect their invariants. Avoid public setters where domain methods encapsulate state transitions (e.g. `contract.Execute(...)`, `disbursement.Approve(...)`). +2. **Multi-Tenancy & Data Isolation**: + * Every query must respect the ambient `TenantId`. Never execute queries or mutations without verifying tenant boundaries (`NFR-007`, `AT-14`). +3. **Auditability (BR-007, AT-12)**: + * Any change modifying financial amounts, approvals, contractual states, risk ratings, or project statuses must generate an `AuditLog` entry detailing `UserId`, `Timestamp`, `Action`, `PreviousValue`, and `NewValue`. +4. **Data Protection (POPIA)**: + * Recipient banking details (`AccountNumber`, `BranchCode`) and beneficiary personal information must be encrypted or masked at rest and restricted via field-level authorization. +5. **Frontend State & API Sync**: + * Use TanStack Query hooks for server caching, optimistic updates, and cache invalidation. + * Forms must use `react-hook-form` with `zod` schemas enforcing mandatory fields at input time. + +--- + +## 10. Role and Claim Architecture (RBAC & Permissions) + +The platform implements a fine-grained, claim-based authorization model across both the backend APIs and frontend web applications. This model is partially implemented in `PostFundManagement.ConsoleUI/src/auth/rbac.ts` and governs screen access, action buttons, status changes, and endpoint execution. + +### 10.1 Role Claims Inventory + +The system defines 15 distinct role claims corresponding to operational domains and feature slices: + +| Claim Key | Label | Scope & Description | +| :--- | :--- | :--- | +| `dashboard` | Executive Dashboard | Strategic macro metrics, portfolio KPIs, and ISO 31000 health views | +| `governance` | Governance & Structure | Holding entities, investment portfolios, programmes, and statutory frameworks | +| `projects_milestones` | Projects & Milestones | Work breakdown structure (WBS), scheduled milestones, activities, and progress | +| `grant_financials` | Grant & Financials | Funding awards, budget line allocations, tranches, and disbursements | +| `evidence_risks` | M&E, Evidence & Risks | Evidence verification queue, indicator actuals, and ISO 31000 risk registers | +| `beneficiaries` | Beneficiaries | Target demographics, POPIA compliance tracking, and direct/indirect outcomes | +| `change_management` | Change Management | Contractual change requests, budget reallocations, timeline variations, and issues | +| `site_visits` | Site Visits | Physical field inspection reports, checklist findings, and geotagged evidence | +| `settings_invites` | Settings Invites | Onboarding invitations, applicant verification links, and notifications | +| `settings_users` | Settings Users | User account provisioning, role assignments, claims, and lockout management | +| `settings_auditlog` | Settings Audit Log | Immutable system-wide activity ledger and audit trail entries | +| `settings_notifications` | Settings Notifications | SMTP engines, notification dispatch logs, and delivery channels | +| `tenant_self_service` | Tenant Self-Service | Tenant-level profile parameters, local branding, and sub-user management | +| `tenant_management` | Tenant Management | System-level multi-tenant directory, partition allocation, and cross-tenant isolation | +| `unrestricted` | Unrestricted Access | System-wide root administration; bypasses granular claim checks | + +### 10.2 Claim Action Values & Permitted Capabilities + +Each claim assigned to a user or role can be granted specific permission levels. The permitted action assignments are: + +* **`view`**: Can view records based on role (read-only access to entities, grids, and details). +* **`add`**: Can add/create records or submit feedback; can change workflow statuses *other than approve*. (Includes `view` capability). +* **`update`**: Can use **approve** status changes in dropdowns and action bars. (Includes `view` and `add` capabilities). +* **`export`**: Can click export buttons (e.g. Spreadsheet/Excel `.xlsx`, PDF reports). (Includes `view` capability). +* **`all`**: Can perform all operations (`view`, `add`, `update`, `export`). Full capability within the claim domain. + +### 10.3 Default Role Claim Map + +The following baseline mappings define the claims assigned to system roles: + +| Role | Assigned Claims | Permitted Scope Summary | +| :--- | :--- | :--- | +| **`Admin`** | `unrestricted` | Full system-wide control across all modules, configuration, and tenant partitions. | +| **`Executive`** | `dashboard`, `governance`, `projects_milestones`, `grant_financials`, `evidence_risks`, `tenant_self_service` | Strategic oversight, macro KPIs, portfolio health, financials, and high-level project monitoring. | +| **`Supervisor`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users` | Operational review, verification of milestones/evidence, site inspections, user/invite administration, and change review. | +| **`Manager`** | `dashboard`, `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites`, `settings_users`, `settings_auditlog`, `settings_notifications`, `tenant_self_service` | Comprehensive operational management, including audit logs, notifications configuration, self-service tenancy, and full project/grant controls. | +| **`Contributor`** | `projects_milestones`, `grant_financials`, `evidence_risks`, `beneficiaries`, `change_management`, `site_visits`, `settings_invites` | Working-level execution: creating activities, submitting milestone evidence, capturing risks, drafting change requests, and logging site observations. | + +### 10.4 Restriction Invariant & Enforcement Rules + +* **Explicit Restriction Rule**: **If a role claim is not present, the user is fully restricted from performing any actions associated with that claim.** +* **Partner Role Isolation**: The external `partner` (grantee/recipient) role is strictly confined to `PostFundManagement.PartnerUI`. Any attempt by a `partner` user to authenticate or access `PostFundManagement.ConsoleUI` routes is completely blocked. +* **Granular Action Checking in UI**: + * Pages and routes are protected via ``. + * Components query capabilities using `const { canView, canAdd, canApprove, canExport } = useRbac()`. + * Approval buttons and status transitions requiring `update` are disabled or hidden if the user's claim assignment does not permit `update` or `all`. + * Export buttons (`.xlsx`, `.pdf`) are disabled or hidden if the user's claim assignment does not permit `export` or `all`. +* **Backend API Enforcement**: + * Endpoints inspect JWT claims (`role_claim:` or array claims) to enforce authorization before executing command/query handlers. + * Violations immediately return `403 Forbidden` and log an audit security event (`NFR-004`, `AT-13`). + +--- + +## 11. Functional API Surface & Endpoint Catalog + +The backend exposes a fully functional RESTful API hosted at `https://api.ifri.khongisa.co.za/` (OpenAPI v1 specification available at `/openapi/v1.json`). All resource routes follow the versioned prefix `/v1/api/`. + +### 11.1 Governance Endpoints (24 Endpoints) +Governs institutional hierarchies, multi-tenant boundaries, investment portfolios, statutory programmes, and portfolio dashboard metrics. + +* **Executive Dashboard & Valuation**: + * `GET /v1/api/governance/dashboard` (`dashboard`): Returns executive macro metrics, ISO 31000 portfolio health status, and top-level KPI aggregations. + * `GET /v1/api/governance/portfolios/{portfolioId}/value` (`getporfoliovalue`): Aggregates total approved budget and disbursed values across all projects in a portfolio. + * `GET /v1/api/governance/programmes/{programmeId}/value` (`getprogrammevalue`): Calculates total project budget values under a specific programme. +* **Funding Instruments (BR-006)**: + * `POST /v1/api/governance/instruments` (`createinstrument`): Registers a new funding instrument (Grant, DFI, Impact Investment, Blended Finance, NGO, Innovation). + * `PUT /v1/api/governance/instruments` (`updateinstrument`): Modifies instrument parameters and rules. + * `GET /v1/api/governance/instruments/{programmeId}` (`getinstruments`): Lists all funding instruments configured for a programme. +* **Organisations & Recipient Master Data**: + * `POST /v1/api/governance/organisations` (`createorganisation`): Creates a new recipient or partner organisation profile. + * `GET /v1/api/governance/organisations` (`getorganisations`): Paginated directory of registered organisations. + * `GET /v1/api/governance/organisations/{organisationId}` (`getorganisation`): Retrieves organisation details, verification state, and banking references. + * `PUT /v1/api/governance/organisations` (`updateorganisation`): Updates organisation master data. +* **Portfolios & Programmes**: + * `POST /v1/api/governance/organisations/portfolio` (`createportfolio`): Creates an investment portfolio linked to an organisation. + * `GET /v1/api/governance/organisations/{organisationId}/portfolio` (`getorganisationportfolios`): Lists portfolios for an organisation. + * `POST /v1/api/governance/organisations/portfolios` (`getportfolios`): Retrieves all active investment portfolios. + * `GET /v1/api/governance/portfolios/{portfolioId}` (`getportfolio`): Retrieves portfolio details and linked programmes. + * `PUT /v1/api/governance/organisations/portfolio` (`updateportfolio`): Updates portfolio properties. + * `GET /v1/api/governance/portfolios/{portfolioId}/programmes` (`getportfolioprogrammes`): Retrieves programmes belonging to a portfolio. + * `GET /v1/api/governance/programmes/{portfolioId}` (`getprogrammes`): Lists all programmes linked to a portfolio. +* **Multi-Tenancy Partitioning (NFR-007, AT-14)**: + * `POST /v1/api/governance/tenants` (`createtenant`): Registers a new system tenant (e.g. government department, DFI). + * `GET /v1/api/governance/tenants` (`gettenantsretrieval`): Lists all system tenants. + * `GET /v1/api/governance/tenants/{tenantId}` (`gettenants`): Retrieves a single tenant profile. + * `PUT /v1/api/governance/tenants` (`updatetenant`): Modifies tenant parameters and subscription status. + * `GET /v1/api/governance/tenants/{tenantIdHash}/organisations` (`gettenantorganisationsretrieval`): Returns all organisations owned and isolated within a specific tenant partition. +* **Budgets**: + * `PUT /v1/api/grants/budgets` (`updatebudget`): Updates budget allocations and approvals. + +### 11.2 Grants, Awards & Contracting Endpoints (14 Endpoints) +Enforces the mandatory controlled lifecycle starting at approved award (BR-001, BR-002). + +* **Award Intake (M01)**: + * `POST /v1/api/grants/awards` (`createaward`): Registers an approved funding award with external reference, amount, period, recipient, and objectives. Automatically triggers initial draft contract creation (BR-002, AT-01). + * `GET /v1/api/grants/awards` (`getawards`): Lists all registered awards. + * `GET /v1/api/grants/awards/{awardId}` (`getaward`): Retrieves detailed award metadata and downstream project links. +* **Budget Management (M05)**: + * `POST /v1/api/grants/budgets` (`createbudget`): Allocates approved budget lines against an award. + * `GET /v1/api/grants/budgets` (`getbudgets`): Lists all project and award budgets. + * `GET /v1/api/grants/budgets/{budgetId}` (`getbudget`): Retrieves specific budget line allocations, commitments, and actual expenditures. +* **Contract & Agreement Governance (M02)**: + * `POST /v1/api/grants/contracts` (`createcontract`): Creates a contract record with configurable clauses and obligations. + * `GET /v1/api/grants/contracts` (`getcontracts`): Lists contracts across the portfolio. + * `GET /v1/api/grants/contracts/{contractId}` (`getcontract`): Retrieves contract metadata, versioning, and execution status. + * `PUT /v1/api/grants/contracts` (`updatecontract`): Updates draft contract terms prior to execution. + * `POST /v1/api/grants/contracts/{contractId}` (`uploadcontract`): Securely uploads signed agreement artifacts to S3 object storage. + * `GET /v1/api/grants/contracts/{contractId}/securelink` (`getcontracturl`): Generates a time-limited pre-signed download URL (default 15 minutes) for the executed contract document. + * `POST /v1/api/grants/contracts/execute` (`executecontract`): Formally executes agreement with digital signatures, locking version 1.0 as the authoritative legal baseline (AT-02). +* **Evidence URLs**: + * `GET /v1/api/evidence/{evidenceId}/securelink` (`getevidenceurl`): Generates pre-signed temporary S3 download link for verified evidence files. + +### 11.3 Projects & Operational Delivery Endpoints (43 Endpoints) +Controls the work breakdown structure (WBS), milestones, field monitoring, and project governance (M04, M07, M10, M13, M15, M16, M17). + +* **Project Management (M04)**: + * `POST /v1/api/projects` (`createproject`): Creates a new project under an approved award and programme. + * `GET /v1/api/projects` (`getprojects`): Lists all active and historical projects. + * `GET /v1/api/projects/{projectId}` (`getproject`): Detailed view of project WBS, timeline, status, and financials. + * `PUT /v1/api/projects` (`updateproject`): Updates project details. + * `PUT /v1/api/projects/status` (`updateprojectstatus`): Updates project status across lifecycle states (`ACTIVE`, `AT RISK`, `COMPLETED`, `CLOSED`). + * `POST /v1/api/projects/programme` (`createprogramme`), `PUT /v1/api/projects/programme` (`updateprogramme`), `GET /v1/api/projects/programme/{programmeId}` (`getprogramme`), `GET /v1/api/projects/programme/{page}/{pageSize}` (`getprogrammesprojects`), `GET /v1/api/projects/{programmeId}/programme` (`getprogrammeprojects`). +* **Activities & WBS (M04)**: + * `POST /v1/api/projects/activities` (`createactivity`): Adds an activity linked to a project milestone. + * `GET /v1/api/projects/activities/{activityId}` (`getactivity`): Retrieves specific activity details. + * `GET /v1/api/projects/{projectId}/activities` (`getactivities`): Lists all WBS activities for a project. +* **Milestone & Deliverables (M07)**: + * `GET /v1/api/projects/{projectId}/milestones` (`getprojectmilestones`): Lists contractual milestones for a project. + * `GET /v1/api/projects/milestones/{milestoneId}` (`getmilestone`): Retrieves milestone requirements, due dates, and evidence attachments. + * `GET /v1/api/projects/milestones/{page}/{pageSize}` (`getmilestones`): Paginated milestone repository. + * `PUT /v1/api/projects/milestone` (`updatemilestone`): Updates milestone dates, statuses, and verification decisions. +* **Beneficiaries & Social Outcomes (M13)**: + * `POST /v1/api/projects/beneficiaries` (`createbeneficiary`): Registers beneficiary group or target demographic. + * `GET /v1/api/projects/beneficiaries/{beneficiaryId}` (`getbeneficiaries`): Retrieves beneficiary details. + * `GET /v1/api/projects/{projectId}/beneficiaries` (`getbeneficiariesretrieval`): Lists beneficiaries for a project. + * `PUT /v1/api/projects/beneficiaries` (`updatebeneficiary`): Updates beneficiary records. + * `POST /v1/api/projects/beneficiaris/outcomes` (`createoutcome`): Links an achieved outcome to a beneficiary group. + * `GET /v1/api/projects/outcomes/{outcomeId}` (`getoutcome`): Retrieves outcome details. + * `GET /v1/api/projects/outcomes/{beneficiatyId}` (`getoutcomes`): Retrieves outcomes for a beneficiary. + * `PUT /v1/api/projects/outcomes` (`updateoutcome`): Updates outcome status. +* **Compliance Management (M10)**: + * `POST /v1/api/projects/complianceitems` (`createcomplianceitem`): Logs a statutory, financial, or contractual compliance obligation. + * `GET /v1/api/projects/complianceitems/{complianceItemId}` (`getcomplianceitem`): Retrieves compliance item status. + * `GET /v1/api/projects/complianceitems/{projectId}` (`getcomplianceitems`), `GET /v1/api/projects/{projectId}/complianceitems`: Lists project compliance tasks. + * `PUT /v1/api/projects/complianceitems` (`updatecomplianceitem`): Updates compliance satisfaction and attached evidence. +* **Risk & Issue Governance (M09, M16)**: + * `GET /v1/api/projects/{projectId}/risks` (`getprojectrisks`): Lists risks registered against a project. + * `POST /v1/api/projects/issues` (`createissue`): Logs an operational issue or escalation blocker. + * `GET /v1/api/projects/issues/{issueId}` (`getissue`): Retrieves issue details and resolution history. + * `GET /v1/api/projects/{projectId}/issues` (`getissues`): Lists all issues for a project. + * `PUT /v1/api/projects/issues` (`updateissue`): Updates issue severity, resolution, or escalation owner. +* **Change Control & Variations (M15, AT-09)**: + * `POST /v1/api/projects/changerequests` (`createchangerequest`): Submits a formal change request (scope, budget, timeline). + * `GET /v1/api/projects/changerequests/{changeRequestId}` (`getchangerequest`): Retrieves change impact evaluation. + * `GET /v1/api/projects/{projectId}/changerequests` (`getchangerequests`): Lists project change requests. + * `PUT /v1/api/projects/changerequests` (`updatechangerequest`): Records review, approval, or rejection of change request. +* **Site Visits & Field Inspections (M17)**: + * `POST /v1/api/projects/sitevisits` (`createsitevisit`): Records scheduled or completed field monitoring visit. + * `GET /v1/api/projects/sitevisits/{siteVisitId}` (`getsitevisit`): Retrieves site visit checklist, inspector observations, and findings. + * `GET /v1/api/projects/{projectId}/sitevisits` (`getsitevisits`): Lists site visits conducted on a project. + * `PUT /v1/api/projects/sitevisits` (`updatesitevisit`): Updates inspection findings and corrective actions. +* **Indicators Linkage (M08)**: + * `GET /v1/api/projects/indicators/project/{projectId}` (`getprojectindicators`): Lists all M&E indicators tracked for a project. + +### 11.4 Evidence & M&E Endpoints (11 Endpoints) +Handles evidence verification and quantitative results measurement (M08, M14). + +* **Supporting Evidence Repository (M14)**: + * `PUT /v1/api/evidence/file` (`createevidence`): Multipart/stream file upload directly to S3 storage, registering SHA-256 hashes, MIME types, and entity linkages. + * `PUT /v1/api/evidence` (`updateevidence`): Updates evidence metadata, verification status, and review notes. + * `GET /v1/api/evidence/{evidenceId}` (`getevidence`): Retrieves evidence record details and download references. + * `GET /v1/api/evidence/files/{page}/{pageSize}` (`getevidencefiles`): Paged catalogue of all evidence assets across the system. + * `GET /v1/api/evidence/milestone/{milestoneId}` (`getmilestoneevidence`): Retrieves all supporting files attached to a specific milestone. + * `GET /v1/api/evidence/{projectId}` (`getprojectevidence`): Retrieves all evidence files registered for a project. +* **M&E Indicators (M08)**: + * `POST /v1/api/evidence/indicators` (`createindicator`): Defines an indicator with baseline, target, unit of measure, and results level. + * `GET /v1/api/evidence/indicators/{page}/{pageSize}` (`getindicators`): Lists all indicators across programmes. + * `GET /v1/api/evidence/indicators/{indicatorId}` (`getindicator`): Detailed view of an indicator's history. + * `POST /v1/api/evidence/indicators/{indicatorId}/actuals/{actualAmount}` (`recordindicatoramount`): Records verified actual results; automatically evaluates variance and RAG status (Green $\ge 90\%$, Amber $70\text{--}89\%$, Red $< 70\%$). +* **Milestone Creation**: + * `POST /v1/api/evidence/milestones` (`createmilestonerequest`): Creates milestone requirements linked to deliverables and evidence rules. + +### 11.5 Execution & Financial Control Endpoints (13 Endpoints) +Strict disbursement release controls and risk matrices (M06, M09). + +* **Disbursement Tranche Releases (M06, BR-003, AT-05)**: + * `POST /v1/api/execution/disbursements` (`createdisbursement`): Creates a planned disbursement tranche linked to a milestone. + * `GET /v1/api/execution/disbursements` (`getdisbursements`): Lists all disbursement schedules across awards. + * `GET /v1/api/execution/disbursements/{disbursementId}` (`getdisbursement`): Retrieves tranche amount, bank details, and conditions precedent status. + * `PUT /v1/api/execution/disbursements` (`updatedisbursement`): Modifies payment schedule details. + * `POST /v1/api/execution/disbursements/{disbursementId}/approve` (`approvedisbursement`): Authorises tranche release. Strictly requires verified milestone evidence and completed onboarding; blocks duplicate payment approvals. +* **Risk Register (M09, AT-07)**: + * `POST /v1/api/execution/risks` (`createrisk`): Registers a project risk with likelihood, impact, and mitigation plan. + * `GET /v1/api/execution/risks` (`getrisks`): Lists risks across the portfolio. + * `GET /v1/api/execution/risks/{riskId}` (`getrisk`): Detailed risk profile including residual risk score. + * `PUT /v1/api/execution/risks` (`updaterisk`): Updates risk status, mitigation progress, or escalates high risks. +* **Programme Rules & Constraints (M04, M23)**: + * `POST /v1/api/execution/rules` (`createrule`): Configures an operational rule or threshold for a programme. + * `PUT /v1/api/execution/rules` (`updaterule`): Amends a rule with auto-incrementing version control. + * `GET /v1/api/execution/rules/{ruleId}` (`getrule`): Retrieves rule definition. + * `POST /v1/api/execution/programmes/rules` (`getrules`): Lists all active rules for a programme. + +### 11.6 Communications & Notifications Endpoints (6 Endpoints) +* **Onboarding & Invitations (M03)**: + * `POST /v1/api/communications/invites` (`sendinvite`): Dispatches onboarding invitation via Email, SMS, or WhatsApp. + * `GET /v1/api/communications/invites/{page}/{pageSize}` (`getinvites`): Lists sent invitations. + * `GET /v1/api/communications/invites/{applicantUserId}` (`getapplicantinvites`): Lists invitations sent to an applicant. + * `GET /v1/api/communications/invites/applicants/{applicantUserId}` (`getapplicantinvite`): Retrieves single invitation details. + * `GET /v1/api/communications/applicants/{applicantUserId}/all` (`getiapplicantdata`): Aggregates all onboarding communications and status for an applicant. +* **Notification Logging**: + * `GET /v1/api/communications/notifications/{organisationId}` (`getnotifications`): Retrieves notification dispatch and delivery logs. + +### 11.7 User Administration & Identity Endpoints (15 Endpoints) +Directly integrated with the hosted Skoruba Duende security system. + +* **User Directory & Synchronization**: + * `POST /v1/api/users/{page}/{pageSize}` (`getusers`): Paged, searchable directory of system users with filtering by status and email. + * `GET /v1/api/users/{userId}` (`getuser`): Retrieves user metadata, assigned roles, and tenant associations. + * `GET /v1/api/users/{userId}/status/{status}` (`updateuserstatus`): Updates user activity status (Active, Inactive, Suspended). + * `POST /v1/api/integration/sync` (`syncuser`): Triggers background synchronization of users and organisations between STS and local DB. +* **User Provisioning & Session Control**: + * `POST /v1/api/users/{tenantId}/{idNumber}` (`createuser`): Provisions a new user account in both Skoruba IdentityServer and the local database. + * `PUT /v1/api/users/{userId}/session/lockout/{disable}` (`setuserlockout`): Immediately locks or unlocks user account access in the identity store. + * `PUT /v1/api/users/{userId}/session/logout` (`loguserout`): Invalidates active user security tokens and ends active sessions. +* **Role & Claim Assignments (RBAC)**: + * `GET /v1/api/users/{userId}/roles` (`getuserroles`): Retrieves roles assigned to a user. + * `PATCH /v1/api/users/{userId}/roles/{roleId}` (`adduserrole`): Assigns a system role to a user. + * `DELETE /v1/api/users/{userId}/roles/{roleId}` (`deleteuserrole`): Revokes a system role from a user. + * `GET /v1/api/users/system/roles` (`getsystemroles`): Discovers all roles configured in the security authority. + * `GET /v1/api/users/system/roles/{roleId}/claims` (`getroleclaims`): Discovers claims associated with a role. + * `GET /v1/api/users/{userId}/claims` (`getuserclaims`): Retrieves direct claims assigned to a user. + * `PATCH /v1/api/users/user/claims` (`adduserclaim`): Attaches an explicit claim value to a user. + * `DELETE /v1/api/users/{targetUserId}/claims/{claimId}` (`deleteuserclaim`): Removes a claim from a user. + +--- + +## 12. Standardized API Response Handling & ProblemDetails Contract + +### 12.1 ProblemDetails Error Protocol +When an endpoint experiences a validation failure, business rule violation, or internal error, it returns a standard RFC 7807 / ASP.NET Core `ProblemDetails` object: + +```json +{ + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "Error", + "status": 400, + "detail": "Approved funding amount must be greater than zero,Funding start date must precede end date" +} +``` + +### 12.2 Critical Client-Side Parsing Rule +In `PostFundManagement.Application/Extensions/General.cs`, error lists from `FluentResults` are joined into the `Detail` property using a comma separator: +```csharp +public static ProblemDetails ToProblems(this IReadOnlyList errors, int statusCode = StatusCodes.Status400BadRequest) => new() +{ + Detail = string.Join(',', errors.Select(e => e.Message)), + Status = statusCode, + Title = "Error", +}; +``` + +> [!IMPORTANT] +> **Crucial Rule for Frontends (`PartnerUI` and `ConsoleUI`)**: +> The `detail` property contains the **actual human-readable error messages**. +> Frontends must **never** display raw generic error titles. Instead, frontends must: +> 1. Inspect `error.response.data.detail`. +> 2. Split the string by `,` (comma) and trim whitespace from each item. +> 3. Display each item as an individual bullet point or toast message. +> +> This behavior is standardized in `PostFundManagement.PartnerUI/src/services/api/errorHelper.ts` (`extractApiErrors` and `formatApiErrorMessage`) and must be maintained across all form handlers. + +--- + +## 13. Hosted Skoruba Duende Security System Integration + +The platform relies on a dedicated, hosted **Skoruba Duende IdentityServer** deployment for authentication, machine-to-machine trust, and identity governance. + +### 13.1 Infrastructure & Authority Configuration +* **STS Authority Endpoint**: `https://sts.security.khongisa.co.za` +* **Skoruba Administration API**: `https://api.security.khongisa.co.za/` (Connection string `SkorubaApi`) +* **API Audience**: `pfm` (configured under `SecuritySettings:Audience`) + +### 13.2 API Security Architecture +1. **Interactive JWT Token Validation**: + * Configured via `AddApiSecurity` in `PostFundManagement.Application/Extensions/Security.cs`. + * Validates tokens against `https://sts.security.khongisa.co.za` using asymmetric key validation. + * `options.MapInboundClaims = false`: Preserves original claim types (`role`, `role_claim:*`, `sub`, `email`) without automatic legacy mapping. + * Enforces `Constants.MainScope` (`pfm` scope) on all protected endpoints. +2. **Machine-to-Machine Administration (Client Credentials)**: + * Configured via `AddSkorubaApiService` using `ClientCredentialsTokenHandler`. + * Automatically requests and caches client credentials tokens from `https://sts.security.khongisa.co.za/connect/token`. + * Communicates with `https://api.security.khongisa.co.za/` via Refit / typed HttpClient to manage users, roles, and claims programmatically. + * Outbound calls are guarded by Polly exponential retry policies (`options.Retry.MaxRetryAttempts = 3`). +3. **Session Revocation & Lockout**: + * `PUT /v1/api/users/{userId}/session/logout` and `/lockout/{disable}` invoke `ISkorubaUserService` to trigger immediate session invalidation in Duende STS, ensuring instant token revocation. + +--- + +## 14. PartnerUI Completion Roadmap + +The client has confirmed that the backend implementation conforms with the SRS v2.0 requirements. In order to complete **`PostFundManagement.PartnerUI`** (the external grantee/recipient portal), the following UI features must be finalized: + +1. **Recipient Onboarding Flow (Module M03)**: + * Interactive wizard for grantee onboarding: organisation profile verification, key personnel registration, and banking details submission. + * Tax clearance and statutory document uploads with real-time status feedback. +2. **Milestone Evidence Submission Wizard (Modules M07 / M14)**: + * Interactive deliverable tracker matching wireframe Figure 10 in the SRS. + * File upload modal with pre-validation (allowed extensions: PDF, JPG, DOCX, XLSX; max size: 25MB). + * Reviewer status feedback display (Submitted $\to$ Under Review $\to$ Verified or Rework Required with reviewer commentary). +3. **Disbursement Tranche Drawdown Requests (Module M06)**: + * Partner drawdown dashboard displaying eligible, submitted, and disbursed funding tranches. + * Drawdown request form with automatic conditions precedent checklist verification. +4. **M&E Indicator Actuals Reporting (Module M08)**: + * Form enabling partners to report periodic actuals against contractual indicator targets. + * Mandatory evidence attachment validation prior to submitting actual metric values. +5. **Post-Fund Monitoring Reporting (Module M19)**: + * Longitudinal survey forms triggering at 6, 12, 24, 36, and 60 months post project close-out. + * Tracking enterprise survival, annual turnover, net jobs maintained, and ongoing commercialisation. +6. **Consistent Error & Notification Handling**: + * Integrate `extractApiErrors()` and `formatApiErrorMessage()` from `errorHelper.ts` across all PartnerUI mutation forms and modals. + +--- + +## 15. Hashids Architecture & Frontend Conformance Insights + +The platform uses **Hashids** to obfuscate internal sequential database primary keys (`int64` / `long` and `int32`), mitigating entity enumeration, sequential parameter tampering, and unauthorized data scraping. + +### 15.1 Backend Hashids Topology & Error Behavior + +* **Implementation**: Managed by `HashService` (`PostFundManagement.Application/Services/Shared/HashService.cs`) using the `Hashids.net` (v1.7.0) library. +* **Shared Configuration (`HasherSettings`)**: + * **Salt**: Sourced from configuration (`HasherSettings:Salt` or `HASHER_SALT`), defaulting to: + `"TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="` + * **Min Hash Length**: `HasherSettings:MinHashLength` or `HASHER_MIN_HASH_LENGTH` (default: `11`). +* **Core Service Methods**: + * `HashEncodeLongId(long id)` / `HashEncodeIntId(int id)`: Encodes integers into obfuscated HashID strings (e.g. `1` $\to$ `kY5aB9m...`). + * `DecodeLongIdHash(string hash)` / `DecodeIntIdHash(string hash)`: Decodes HashID strings back into database IDs. + * `HashEncodeHex(string hex)` / `DecodeHexHash(string hex)`: Encodes and decodes arbitrary alphanumeric strings (such as South African 13-digit National ID numbers and passport numbers) via hexadecimal conversions. +* **Security & Failure Contract (`IdDecodeError` $\to$ 403 Forbidden)**: + * When an endpoint receives an invalid, corrupted, or tampered HashID, `DecodeLongIdHash` or `DecodeHexHash` fails and returns an `IdDecodeError`. + * Across Minimal API endpoints (e.g. `GetProjectEndpoint`, `GetTenantOrganisationsEndpoint`, `CreateUserEndpoint`), `IdDecodeError` is mapped explicitly: + ```csharp + var r when r.HasError() => Results.Forbid() + ``` + * As a result, passing an unhashed raw numeric ID (e.g. `/v1/api/projects/1`) or an invalid hash immediately yields **HTTP 403 Forbidden**, preventing route scanning and guessing attacks. + +### 15.2 Path Parameters vs. Request Body JSON Payloads (The Core Rule) + +A critical distinction governs the communication between frontends and the API: + +``` +┌────────────────────────────────────────────────────────────────────────────────────────┐ +│ URL Path Parameters: HASHID STRINGS ONLY │ +│ E.g. GET /v1/api/projects/{projectId} ──> expects "kY5aB9m..." (HashID) │ +│ E.g. GET /v1/api/governance/tenants/{tenantIdHash}/organisations ──> expects HashID │ +│ E.g. POST /v1/api/users/{tenantId}/{idNumber} ──> expects HashID + Hex HashID │ +└────────────────────────────────────────────────────────────────────────────────────────┘ + vs. +┌────────────────────────────────────────────────────────────────────────────────────────┐ +│ Request Body JSON Payloads: NATIVE NUMERIC INTEGERS ONLY │ +│ E.g. POST /v1/api/projects ──> { "programmeId": 12, "name": "..." } (int64 integer) │ +│ E.g. PUT /v1/api/projects/status ──> { "projectId": 45, "status": 2 } (int64 integer) │ +│ OpenAPI Schema: { "type": "integer", "format": "int64", "pattern": "^-?(0|[1-9]\\d*)$"} │ +└────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +> [!WARNING] +> **Payload Failure Trap**: +> If an alphanumeric HashID string is passed inside a JSON request body where an integer is expected, ASP.NET Core deserialization and OpenAPI validation will reject the request with **HTTP 400 Bad Request**. +> Conversely, if a raw integer is passed in a URL route parameter where a HashID is expected, `DecodeLongIdHash` will fail to decode, returning **HTTP 403 Forbidden**. + +### 15.3 How ConsoleUI Successfully Conforms + +`PostFundManagement.ConsoleUI` achieves seamless, defect-free conformance through an isolated, two-pronged abstraction layer: + +#### 1. Instant Client-Side Deterministic Hasher (`src/utils/hashids.ts` & `src/services/toolsService.ts`) +* ConsoleUI bundles the JavaScript `hashids` library initialized with the exact same `salt` and `minHashLength` as the backend. +* All encoding and decoding occurs **synchronously and client-side with zero network round-trips**. +* `isHashId(str)` guards against accidental double-encoding if an identifier has already been hashed. +* Specialized encoders: + * `encodeEntityId(id)` / `toolsService.resolveProjectId(id)`: Encodes database numeric IDs for URL route parameters. + * `encodeIdNumberHex(idNumber)`: Encodes South African national ID numbers (13 digits), passports, and alphanumeric keys into hex-encoded Hashids for the `/api/users/{tenantId}/{idNumber}` provisioning endpoint. + +#### 2. Request Body Sanitization via `toIntegerId()` in `src/services/api.ts` +* In `api.ts`, outgoing API calls systematically decouple route parameters from JSON payload bodies: + * **Route Parameters**: Routed via `resolveProjectId(projectId)` or `encodeEntityId(id)` to produce valid HashIDs. + * **Payload Bodies**: Routed via `toIntegerId(payload.id)` (`toolsService.decodeToNumberSync(id)`). + ```typescript + export const toIntegerId = (id: number | string | null | undefined): number => { + return toolsService.decodeToNumberSync(id); + }; + ``` + * `toIntegerId` detects whether the passed ID is a HashID, a numeric string, or a number, and safely decodes it into a canonical integer number before Axios serializes the JSON request body. + +#### 3. Component Decoupling +* UI components (`ProjectsPage.tsx`, `GrantsPage.tsx`, `SettingsPage.tsx`, `CreateUserModal.tsx`) do not perform manual hash transformations or math. +* They simply pass identifiers into `api.ts` methods, which transparently route HashIDs to URLs and integer numbers to request bodies. + +### 15.4 Conformance Guidelines for PartnerUI Completion + +When implementing remaining features in `PostFundManagement.PartnerUI`, follow the proven ConsoleUI pattern: +1. **Route Parameters**: Use `hashParam(id)` (already established in `PartnerUI/src/services/api/endpoints.ts`) for all URL paths. +2. **Payload Bodies**: Always decode incoming IDs to integer numbers when constructing POST/PUT request bodies for Milestones, Evidences, Drawdown requests, and Indicator actuals. +3. **Hex ID Encoding**: When capturing user national IDs or identification numbers, apply `encodeIdNumberHex()` before invoking user endpoints. +4. **403 Forbidden Detection**: Treat HTTP 403 on parameterized GET routes as a potential `IdDecodeError` caused by an invalid or unhashed route parameter. + +--- + +## 16. .NET Project Architecture, Coding Style & Interceptor-Based Governance + +The backend is built with **.NET 10.0 (C# 13)** using Domain-Driven Design (DDD), Clean Architecture, and Aspect-Oriented Programming (AOP). + +### 16.1 Layered Architecture & Project Responsibilities + +``` +PostFundManagement.Api + ├── Endpoints/ # Minimal API slices implementing IEndpoint + ├── Filters/ # Endpoint filters (ActiveUserFilter, UserRoleFilter) + └── Program.cs # Middleware pipeline, DI composition, OpenAPI transformer + │ + ▼ (References) +PostFundManagement.Application + ├── Services/ # Domain services grouped by slice (Projects, Grants, etc.) + │ ├── [Slice]Service.cs # Actual business logic + │ └── [Slice]CacheService.cs # HybridCache decorator + ├── Interceptors/ # Castle DynamicProxy AOP (LoggingInterceptor, AuditInterceptor) + ├── Extensions/ # Service registration & DI wiring (Services.cs, Security.cs) + └── Abstractions/ # Service interfaces (IProjectService, IGrantsService) + │ + ├─────────────────────────────┐ + ▼ ▼ +PostFundManagement.Infrastructure PostFundManagement.Domain + ├── Database/ ├── Entities/ # Aggregate roots & entities + │ ├── ApplicationDbContext.cs ├── Enums/ # System taxonomies + │ └── Configurations/ ├── Errors/ # Domain errors (IError) + ├── Security/ ├── Attributes/ # [Log], [Audit], [ApiVersionTarget] + └── Migrations/ └── Abstractions/ # IEntity, IAggregateRoot, IService +``` + +### 16.2 C# 13 & .NET 10 Coding Conventions + +1. **Primary Constructors & Modern Types**: + * Services and endpoints use primary constructors: + ```csharp + public sealed class ProjectService( + IDbContextFactory contextFactory, + HashService hashService, + ITenantContextService tenantContext) : IProjectService + ``` + * Extensive use of expression-bodied members (`=>`) for concise method definitions. + * Nullable reference types (`enable`) strictly enforced. +2. **Functional Result Pattern (`FluentResults`)**: + * Domain and Application services **never throw exceptions for predictable business validation failures**. + * Methods return `Task` or `Task>`. + * Standard error objects in `PostFundManagement.Domain.Errors`: + * `ValidationError`: Mandatory field missing, format invalid, or constraint violation. + * `NotFoundError`: Entity ID not found in database. + * `UpdateError`: Mutation failed or produced zero affected rows. + * `CreateError`: Entity creation failed during persistence. + * `IdDecodeError`: HashID token corrupted, invalid salt, or tampering detected. +3. **Unified Exception-to-Result Wrapping**: + * Service methods wrap their implementation inside `Result.Try`: + ```csharp + public async Task> GetProjectAsync(string projectIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + // Execution logic... + })); + ``` + * Catches unhandled exceptions and converts them safely into `Result.Fail` without crashing the thread or leaking raw stack traces. +4. **Pattern-Matching Endpoint Handlers**: + * Minimal API endpoints map `Result` directly to HTTP status codes via C# switch expressions: + ```csharp + await service.UpdateProjectStatusAsync(...) switch + { + { IsSuccess: true } => Results.Ok(), + var r when r.HasError() => Results.Forbid(), + var r when r.HasError() => Results.NotFound(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + } + ``` +5. **High-Concurrency DbContext Management**: + * Services inject `IDbContextFactory` rather than scoped `ApplicationDbContext`. + * Creates short-lived contexts using `await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);`. + * Avoids thread concurrency issues in parallel mediator requests and background Quartz jobs. +6. **Query & Mutation Optimizations**: + * Queries use `.AsNoTracking()` to eliminate EF Core tracking overhead. + * Targeted updates leverage `.ExecuteUpdateAsync(...)` for in-database bulk updates without loading full entities into memory. +7. **Tiered Hybrid Caching**: + * Implements `Microsoft.Extensions.Caching.Hybrid` alongside StackExchange Redis. + * Local L1 memory cache: 30-minute expiration. + * Distributed L2 Redis cache: 2-minute expiration (`InstanceName = "PFM-API_"`). + +--- + +### 16.3 Logging Strategy Using `LoggingInterceptor` & `[Log]` + +The application uses Aspect-Oriented Programming (AOP) via **Castle DynamicProxy** and `Castle.Core.AsyncInterceptor` to decouple logging from core domain logic. + +#### How `LoggingInterceptor` Works +* **Source**: [`PostFundManagement.Application/Interceptors/LoggingInterceptor.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Interceptors/LoggingInterceptor.cs). +* **Trigger**: Decorated with `[Log]` attribute on service interface methods. +* **Execution Flow**: + 1. Intercepts both synchronous (`InterceptSynchronous`) and asynchronous (`InterceptAsynchronous`) calls. + 2. Awaits the target method execution to retrieve the `resultObj`. + 3. Checks if `resultObj is IResultBase result && result.IsFailed`. + 4. If failed: + * Discovers target method information. + * Sanitizes and serializes invocation arguments via `BuildPayloadSummary(parameters, args)`: + * Form files are summarized: `[File] Name='...', Size=... bytes`. + * Streams are summarized: `[Stream] Length=... bytes`. + * Cancellation tokens are excluded. + * DTOs/Requests are serialized to JSON. + * Extracts error reasons and error type: + ```csharp + var errorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty); + var errorsString = string.Join("; ", errorMessages); + ``` + * Emits a structured log via `ILogger`: + ```csharp + logger.LogError("Method {MethodName} failed. Errors: [{ErrorType}] {Errors}. Parameters: {@Parameters}", + method.Name, errorType, errorsString, parameters); + ``` + 5. The original `resultObj` is returned to the caller without alteration. + +--- + +### 16.4 Audit Strategy Using `AuditInterceptor`, `[Audit]` & `AuditService` + +The audit trail is a strict regulatory requirement (`BR-007`, `NFR-008`, `AT-12`). Rather than writing manual audit code in every handler, the platform automates audit logging via `AuditInterceptor`. + +#### How `AuditInterceptor` Works +* **Source**: [`PostFundManagement.Application/Interceptors/AuditInterceptor.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Interceptors/AuditInterceptor.cs). +* **Trigger**: Decorated with `[Audit]` attribute on service interface methods. +* **Execution Flow**: + 1. Intercepts asynchronous execution: `InterceptAsynchronous(IInvocation invocation)`. + 2. Inspects `resultObj`: Auditing executes **only when `result.IsSuccess` is true**. + 3. Automatically resolves audit attributes from runtime reflection: + * **Action**: Strips `Async` suffix from method name (`CreateProjectAsync` $\to$ `CreateProject`). + * **EntityName**: Inferred from return value generic type (`Result` $\to$ `Project`) or request argument name (`CreateProjectRequest` $\to$ `Project`). + * **EntityId**: Inspects the returned object's `Value.Id` property or checks invocation arguments for an `Id`/`id` property or primitive key. + * **Changes**: Serializes parameters into a formatted payload summary (`BuildPayloadSummary`). + * **PerformedBy**: Extracts the authenticated user's `Guid` from `IHttpContextAccessor.HttpContext.User` claims (`sub` or `NameIdentifier`). + 4. Asynchronously invokes `AuditService.LogActionAsync(...)`: + ```csharp + await auditService.LogActionAsync( + entityId: entityId, + entityName: entityName, + action: action, + changes: changes, + performedBy: performedBy).ConfigureAwait(false); + ``` + 5. Persists an immutable entry to `context.AuditLogs` with UTC timestamp and ambient `TenantId`. + 6. **Resilience**: The entire audit block is wrapped in a non-throwing catch block so audit database write issues never fail a successful business transaction. + +--- + +### 16.5 AOP Service Registration Pipeline + +All application services are registered through a unified decorator-and-proxy composition in [`PostFundManagement.Application/Extensions/ServiceExtensionTools.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Extensions/ServiceExtensionTools.cs) and [`Services.cs`](file:///c:/Users/Admin/source/repos/PostFundManagement/PostFundManagement.Application/Extensions/Services.cs): + +```csharp +services.AddLoggedAuditedCachedServiceScoped(); +services.AddLoggedAuditedCachedServiceScoped(); +services.AddLoggedAuditedCachedServiceScoped(); +services.AddLoggedAuditedCachedServiceScoped(); +services.AddLoggedAuditedCachedServiceScoped(); +services.AddLoggedAuditedCachedServiceScoped(); +services.AddLoggedAuditedCachedServiceScoped(); +``` + +#### Execution Pipeline Order + +``` +Caller (e.g. Minimal API Endpoint) + │ + ▼ +1. Castle DynamicProxy Interface Proxy + │ + ├─► [LoggingInterceptor]: Intercepts call; logs structured error if Result.IsFailed + │ + ├─► [AuditInterceptor]: Intercepts call; records immutable AuditLog if Result.IsSuccess + │ + ▼ +2. [Slice]CacheService (Cache Decorator) + │ Checks HybridCache (L1 Memory / L2 Redis); returns cached response on read hits + │ + ▼ (Cache Miss / Mutation) +3. [Slice]Service (Actual Implementation) + │ Executes business invariants, EF Core queries, and state persistence + │ + ▼ +Database (PostgreSQL / Npgsql) +``` + +This composition ensures: +* **Zero Boilerplate**: Domain services do not write logging or auditing code. +* **Separation of Concerns**: Caching, logging, auditing, and business rules remain in isolated, testable classes. +* **Guaranteed Auditability**: Any method tagged with `[Audit]` automatically satisfies `BR-007` and `AT-12`. + + + + + -- 2.47.3 From d7a168c9fa98aeb4e52526ee9ed5d3a772660222 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 20 Oct 2026 12:47:06 +0200 Subject: [PATCH 2/4] Create Organisation Fix --- GEMINI.md | 11 + .../Endpoints/Users/GetUsersEndpoint.cs | 5 +- .../Services/Governance/GovernanceService.cs | 4 +- .../Services/Governance/Records.cs | 6 +- .../Services/Users/UserCacheService.cs | 27 +- PostFundManagement.ConsoleUI/package.json | 2 + .../src/components/ui/ExportButton.tsx | 4 +- .../src/components/ui/Modal.tsx | 17 +- .../src/components/ui/ObfuscatedIdNumber.tsx | 82 ++++ .../src/components/users/CreateUserModal.tsx | 20 +- .../components/users/UserSecurityModal.tsx | 11 +- .../src/pages/GovernancePage.tsx | 372 +++++++++++++----- .../src/pages/SettingsPage.tsx | 80 +++- .../src/services/api.ts | 2 +- PostFundManagement.ConsoleUI/src/types.ts | 4 +- PostFundManagement.ConsoleUI/yarn.lock | 17 + .../Database/Services/TenantContextService.cs | 18 +- 17 files changed, 527 insertions(+), 155 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx diff --git a/GEMINI.md b/GEMINI.md index c2e85e3..9eccc89 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -90,6 +90,7 @@ PostFundManagement/ ### 2.2 Frontend Projects (React 19 / TypeScript) #### **`PostFundManagement.ConsoleUI`** (Internal Staff / Portfolio Administration Portal) +* **Package Manager**: **Yarn** (`yarn.lock` is authoritative; `npm` must not be used) * **Runtime & Bundler**: React 19.0.1, TypeScript 5.8.2, Vite 6.2.3 * **Styling**: Tailwind CSS v4.1.14 (`@tailwindcss/vite`, `@tailwindcss/postcss`) * **Routing**: React Router DOM v7.18.3 @@ -102,6 +103,7 @@ PostFundManagement/ * **Target Users**: Funding Administrators, Programme Managers, Finance Officers, M&E Officers, Risk Managers, System Administrators, Executive Leadership. #### **`PostFundManagement.PartnerUI`** (External Recipient / Grantee Portal) +* **Package Manager**: **Yarn** (`yarn.lock` is authoritative; `npm` must not be used) * **Runtime & Bundler**: React 19.0.1, TypeScript 7.0.2, Vite 8.3.0 * **Styling & Effects**: Tailwind CSS v4.3.3, `motion` 12.23.24, `ogl` 1.0.11 (WebGL visual elements like MoltenMetal, BorderGlow, SpecularButton) * **Routing**: React Router DOM v7.18.4 @@ -479,6 +481,15 @@ When adding or modifying code in this codebase, strictly follow these standards: 5. **Frontend State & API Sync**: * Use TanStack Query hooks for server caching, optimistic updates, and cache invalidation. * Forms must use `react-hook-form` with `zod` schemas enforcing mandatory fields at input time. +6. **Frontend Package Management (Yarn Mandatory)**: + * **Always use `yarn` instead of `npm`** across all frontend projects (`PostFundManagement.ConsoleUI` and `PostFundManagement.PartnerUI`). + * Never run `npm install`, `npm run`, or create/modify `package-lock.json`. + * Standard commands: + * `yarn install` or `yarn` to install dependencies. + * `yarn add ` / `yarn add -D ` to add dependencies. + * `yarn build` to trigger production builds. + * `yarn dev` to launch local development servers. + * `yarn test` for executing frontend tests. --- diff --git a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs index 15e610c..9a9798c 100644 --- a/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Users/GetUsersEndpoint.cs @@ -28,7 +28,6 @@ public class GetUsersEndpoint : IEndpoint .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status500InternalServerError) - .WithTags(EndpointTags.Users) - .CacheOutput(); + .WithTags(EndpointTags.Users); } -} \ No newline at end of file +} diff --git a/PostFundManagement.Application/Services/Governance/GovernanceService.cs b/PostFundManagement.Application/Services/Governance/GovernanceService.cs index a8ea813..265a2bf 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceService.cs @@ -208,9 +208,9 @@ public sealed class GovernanceService(IDbContextFactory co linkedUser!.IdNumber = request.IdNumber.Clean(); } - var userUpdated = await context.SaveChangesAsync(cancellationToken) > 0; + await context.SaveChangesAsync(cancellationToken); - return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess && userUpdated, UpdateError.Create("Failed to update organisation")); + return Result.OkIf(changedTotal > 0 && updateResult.IsSuccess, UpdateError.Create("Failed to update organisation")); })); public async Task CreatePortfolioAsync(CreatePortfolioRequest request, Guid userId, CancellationToken cancellationToken = default) => diff --git a/PostFundManagement.Application/Services/Governance/Records.cs b/PostFundManagement.Application/Services/Governance/Records.cs index a8f3d7c..8700b08 100644 --- a/PostFundManagement.Application/Services/Governance/Records.cs +++ b/PostFundManagement.Application/Services/Governance/Records.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Domain; +using PostFundManagement.Domain; namespace PostFundManagement.Application.Services.Governance; @@ -37,7 +37,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId, [Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string RegistrationNo, [Required][EmailAddress][MaxLength(LabelLength)] string Email, - [Required][MaxLength(LabelLength)] string IdNumber, + [MaxLength(LabelLength)] string? IdNumber, [Required] Guid OrganisationUser, [Required] OrganisationType Type, [Required] ActivityStatus Status); @@ -45,7 +45,7 @@ public sealed record UpdateOrganisationRequest([Required] long OrganisationId, public sealed record CreateOrganisationRequest([Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(LabelLength)] string RegistrationNo, [Required][EmailAddress][MaxLength(LabelLength)] string Email, - [Required][MaxLength(LabelLength)] string IdNumber, + [MaxLength(LabelLength)] string? IdNumber, [Required] Guid OrganisationUser, [Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany); diff --git a/PostFundManagement.Application/Services/Users/UserCacheService.cs b/PostFundManagement.Application/Services/Users/UserCacheService.cs index cee62f5..03d4890 100644 --- a/PostFundManagement.Application/Services/Users/UserCacheService.cs +++ b/PostFundManagement.Application/Services/Users/UserCacheService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -151,30 +151,7 @@ public sealed class UserCacheService(UserService service, IDistributedCache cach })); public async Task> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default) => - await Result.Try((Func>>)(async () => - { - if (!await featureManager.IsEnabledAsync(ServiceCachingFeature)) - return await service.GetUsersAsync(pagination, request, cancellationToken); - - var cacheKey = HashService.ToMd5Hash($"getusers-{pagination.Page}-{pagination.PageSize}-" + - $"{request.Email}-{request.Status}-{request.SortBy}-{request.SortDescending}").Value; - - var cached = await cache.GetStringAsync(cacheKey, cancellationToken); - - if (string.IsNullOrWhiteSpace(cached)) - { - var usersResult = await service.GetUsersAsync(pagination, request, cancellationToken); - - if (usersResult.IsFailed) return usersResult; - - await cache.SetStringAsync(cacheKey, usersResult.Value.ToJson(), - cacheOptions, cancellationToken); - - return usersResult; - } - - return Result.Ok(cached.FromJson()); - })); + await service.GetUsersAsync(pagination, request, cancellationToken); public async Task LogoutUserAsync(Guid targetUserId, Guid userId, CancellationToken cancellationToken = default) => await service.LogoutUserAsync(targetUserId, userId, cancellationToken); diff --git a/PostFundManagement.ConsoleUI/package.json b/PostFundManagement.ConsoleUI/package.json index 27b413f..33debf2 100644 --- a/PostFundManagement.ConsoleUI/package.json +++ b/PostFundManagement.ConsoleUI/package.json @@ -40,6 +40,8 @@ "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^22.14.0", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "autoprefixer": "^10.4.21", "esbuild": "^0.25.0", "postcss": "^8.5.28", diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx index 2ab69e6..bdf88a7 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/ExportButton.tsx @@ -82,7 +82,7 @@ export function ExportButton({ columns, data, tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, - userEmail: user?.profile?.email || (claims as any)?.email || '', + userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', }); }; @@ -96,7 +96,7 @@ export function ExportButton({ columns, data, tenantName: (claims as any)?.tenant_name || (claims as any)?.tenant || `Tenant #${tenantId || 1}`, - userEmail: user?.profile?.email || (claims as any)?.email || '', + userEmail: (user as any)?.profile?.email || user?.email || (claims as any)?.email || '', }); }; diff --git a/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx b/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx index cfad194..fded33c 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/Modal.tsx @@ -2,14 +2,15 @@ import React, { ReactNode, useEffect } from 'react'; import { X } from 'lucide-react'; import { HelpTooltip } from './HelpTooltip'; -interface ModalProps { +export interface ModalProps { isOpen: boolean; onClose: () => void; title: string; subtitle?: string; helpText?: ReactNode; children: ReactNode; - maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; + maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full'; + size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | 'full' | string; } export const Modal: React.FC = ({ @@ -20,6 +21,7 @@ export const Modal: React.FC = ({ helpText, children, maxWidth = 'lg', + size, }) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -43,13 +45,20 @@ export const Modal: React.FC = ({ if (!isOpen) return null; - const maxWidthClass = { + const effectiveSize = (size || maxWidth) as string; + const maxWidthClassMap: Record = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl', - }[maxWidth]; + '3xl': 'max-w-3xl', + '4xl': 'max-w-4xl', + '5xl': 'max-w-5xl', + '6xl': 'max-w-6xl', + full: 'max-w-full', + }; + const maxWidthClass = maxWidthClassMap[effectiveSize] || 'max-w-lg'; return (
diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx new file mode 100644 index 0000000..bb82008 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/ui/ObfuscatedIdNumber.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import { Eye, EyeOff, Copy, Check } from 'lucide-react'; +import { useToast } from './Toast'; + +export interface ObfuscatedIdNumberProps { + idNumber?: string | null; + className?: string; + allowToggle?: boolean; +} + +export function maskIdNumber(id: string): string { + const clean = id ? id.trim() : ''; + if (clean.length <= 4) { + return '••••'; + } + if (clean.length <= 8) { + return `${clean.slice(0, 2)}••••${clean.slice(-2)}`; + } + // SA ID (13 digits) or standard national IDs: keep first 6 (DOB), mask middle 5, show last 2 + return `${clean.slice(0, 6)}•••••${clean.slice(-2)}`; +} + +export function ObfuscatedIdNumber({ + idNumber, + className = '', + allowToggle = true, +}: ObfuscatedIdNumberProps): React.ReactElement { + const [isRevealed, setIsRevealed] = useState(false); + const [copied, setCopied] = useState(false); + const { success } = useToast(); + + if (!idNumber) { + return -; + } + + const rawId: string = idNumber; + + const handleCopy = async (e: React.MouseEvent): Promise => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(rawId); + setCopied(true); + success('Copied to Clipboard', 'ID Number copied.'); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard fallback + } + }; + + const handleToggle = (e: React.MouseEvent): void => { + e.stopPropagation(); + setIsRevealed((prev) => !prev); + }; + + const displayedText = isRevealed ? rawId : maskIdNumber(rawId); + + return ( +
+ {displayedText} + + {allowToggle && ( + + )} + + +
+ ); +} diff --git a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx index 4c95cec..e288d7b 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/CreateUserModal.tsx @@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { UserPlus, Shield, Hash, Mail, Phone, Lock, Building, CheckCircle2 } from 'lucide-react'; import { Modal } from '../ui/Modal'; import { useToast } from '../ui/Toast'; -import { createUser, getSystemRoles, assignUserRole, getTenants } from '../../services/api'; +import { createUser, getSystemRoles, assignUserRole, getTenants, getUsers } from '../../services/api'; import { useAuth } from '../../auth/AuthProvider'; import { useRbac } from '../../auth/rbac'; import { CreateUserRequest, Role, Tenant } from '../../types'; @@ -136,8 +136,22 @@ export const CreateUserModal: React.FC = ({ await createUser(values.tenantId, values.idNumber.trim(), payload); - // If an initial role was chosen and we have a newly generated user, - // invalidate query cache to refresh directory + if (values.initialRoleId) { + try { + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 1000, { email: values.email.trim() }); + const createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === values.email.trim().toLowerCase() + ); + if (createdUser) { + await assignUserRole(createdUser.id, values.initialRoleId); + } + } catch { + // Graceful fallback + } + } + return values; }, onSuccess: (values) => { diff --git a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx index b091721..787a0ed 100644 --- a/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/users/UserSecurityModal.tsx @@ -32,6 +32,7 @@ import { getSystemClaims, } from '../../services/api'; import { HelpTooltip } from '../ui/HelpTooltip'; +import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber'; import { decodeIdNumberHex } from '../../utils/hashids'; import { User, Role, Claim, ActivityStatus } from '../../types'; import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters'; @@ -298,10 +299,14 @@ export const UserSecurityModal: React.FC = ({
Id Number: -
- {effectiveUser.idNumber || '—'} +
+ {decodedId && decodedId !== effectiveUser.idNumber && ( - (Decoded: {decodedId}) +
+ (Decoded: + + ) +
)}
diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index b755163..504ca92 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -21,6 +21,11 @@ import { FileText, Scale, Calendar, + UserPlus, + ChevronDown, + ChevronUp, + CheckCircle2, + Loader2, } from 'lucide-react'; import { getOrganisations, @@ -50,6 +55,10 @@ import { getProgrammeValue, getUsers, getUser, + createUser, + assignUserRole, + getSystemRoles, + getCurrentUserTenantId, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; @@ -72,6 +81,8 @@ import { OrganisationType, ActivityStatus, ApprovalStatus, + Role, + CreateUserRequest, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, @@ -96,8 +107,7 @@ const organisationSchema = z.object({ name: z.string().min(3, 'Name must be at least 3 characters'), registrationNo: z.string().min(4, 'Registration number is mandatory'), email: z.string().email('Enter a valid email address'), - idNumber: z.string().min(4, 'Government issued ID number is required'), - organisationUser: z.string().min(1, 'Please select a linked user'), + organisationUser: z.string().min(1, 'Please select a linked executive or manager user'), type: z.coerce.number().min(1).max(27), status: z.coerce.number().optional(), }); @@ -232,6 +242,107 @@ export const GovernancePage: React.FC = () => { staleTime: 5 * 60 * 1000, }); + const { data: systemRoles = [] } = useQuery({ + queryKey: ['system-roles'], + queryFn: getSystemRoles, + enabled: isOrgModalOpen, + }); + + const assignableRolesForOrg = React.useMemo(() => { + return systemRoles.filter( + (r) => r.name.toLowerCase() !== 'admin' && r.id !== '1' + ); + }, [systemRoles]); + + const executiveAndManagerUsers = React.useMemo(() => { + return usersDirectory.filter((u) => { + const role = String(u.role || '').toLowerCase(); + return role === 'executive' || role === 'manager'; + }); + }, [usersDirectory]); + + // Quick Add Executive User Subform state + const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false); + const [quickUserEmail, setQuickUserEmail] = useState(''); + const [quickUserName, setQuickUserName] = useState(''); + const [quickUserPhone, setQuickUserPhone] = useState(''); + const [quickUserIdNumber, setQuickUserIdNumber] = useState(''); + const [quickUserRoleId, setQuickUserRoleId] = useState(''); + const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false); + const [quickUserError, setQuickUserError] = useState(null); + + React.useEffect(() => { + if (!quickUserRoleId && assignableRolesForOrg.length > 0) { + const execRole = assignableRolesForOrg.find((r) => r.name.toLowerCase() === 'executive'); + if (execRole) setQuickUserRoleId(execRole.id); + else setQuickUserRoleId(assignableRolesForOrg[0].id); + } + }, [assignableRolesForOrg, quickUserRoleId]); + + const handleQuickAddUser = async () => { + setQuickUserError(null); + if (!quickUserEmail || !quickUserEmail.includes('@')) { + setQuickUserError('Please provide a valid email address.'); + return; + } + if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) { + setQuickUserError('Government issued ID number is required (min 4 characters).'); + return; + } + + setIsSubmittingQuickUser(true); + try { + const tenantId = getCurrentUserTenantId() || 1; + const payload: CreateUserRequest = { + email: quickUserEmail.trim().toLowerCase(), + userName: quickUserName.trim() || quickUserEmail.split('@')[0], + emailConfirmed: true, + phoneNumber: quickUserPhone.trim() || null, + phoneNumberConfirmed: false, + LockoutEnabled: false, + twoFactorEnabled: false, + accessFailedCount: 0, + }; + + await createUser(tenantId, quickUserIdNumber.trim(), payload); + + // Invalidate queries and fetch fresh user list immediately + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + const freshUsers = await getUsers(1, 200); + + const createdUser = freshUsers.find( + (u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase() + ); + + if (createdUser && quickUserRoleId) { + try { + await assignUserRole(createdUser.id, quickUserRoleId); + await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); + await queryClient.invalidateQueries({ queryKey: ['users'] }); + } catch { + // Handled gracefully + } + orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); + } else if (createdUser) { + orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); + } + + success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`); + setQuickUserEmail(''); + setQuickUserName(''); + setQuickUserPhone(''); + setQuickUserIdNumber(''); + setIsQuickAddUserOpen(false); + } catch (err: any) { + const errorDetail = err?.response?.data?.detail || err?.message || 'Failed to create user.'; + setQuickUserError(errorDetail); + toastError('Failed to create user', err); + } finally { + setIsSubmittingQuickUser(false); + } + }; + const { data: organisations = [], isLoading: loadingOrgs, @@ -427,7 +538,6 @@ export const GovernancePage: React.FC = () => { name: '', registrationNo: '', email: '', - idNumber: '', organisationUser: '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, @@ -463,7 +573,6 @@ export const GovernancePage: React.FC = () => { name: data.name, registrationNo: data.registrationNo, email: data.email, - idNumber: data.idNumber, organisationUser: data.organisationUser, type: Number(data.type), status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, @@ -473,7 +582,6 @@ export const GovernancePage: React.FC = () => { name: data.name, registrationNo: data.registrationNo, email: data.email, - idNumber: data.idNumber, organisationUser: data.organisationUser, type: Number(data.type), }); @@ -553,24 +661,23 @@ export const GovernancePage: React.FC = () => { const openCreateOrg = () => { setEditingOrg(null); - const defaultUserId = usersDirectory[0]?.id || sub || currentUserId || ''; - const defaultUser = usersDirectory.find((u) => u.id === defaultUserId); + const defaultUser = executiveAndManagerUsers[0]; orgForm.reset({ name: '', registrationNo: '', email: '', - idNumber: defaultUser?.idNumber || '', - organisationUser: defaultUserId, + organisationUser: defaultUser?.id || '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }); + setIsQuickAddUserOpen(false); + setQuickUserError(null); setIsOrgModalOpen(true); }; const openEditOrg = (org: Organisation) => { setEditingOrg(org); - // 1. Resolve target linked user ID synchronously from the organisation model const targetUserId = org.userId || (org as any).UserId || @@ -579,46 +686,24 @@ export const GovernancePage: React.FC = () => { org.createdBy || ''; - // 2. Fast O(1) in-memory match from cached users directory const matchedUser = targetUserId ? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase()) : null; - // 3. Resolve idNumber: prefer matchedUser's idNumber, or org's idNumber if any, otherwise blank '' - const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || (org as any).IdNumber || ''; const selectedUserId = matchedUser?.id || targetUserId || ''; - // 4. Perform assignment immediately to form state orgForm.reset({ name: org.name || '', registrationNo: org.registrationNo || '', email: org.email || '', - idNumber: resolvedIdNumber, organisationUser: selectedUserId, type: org.type || OrganisationType.Enterprise, status: org.status ?? ActivityStatus.Active, }); - // 5. Instantly open modal with fully populated data (no lag, no progressive pop-in) + setIsQuickAddUserOpen(false); + setQuickUserError(null); setIsOrgModalOpen(true); - - // 6. Non-blocking background sync if target user wasn't in cache or org has missing data - if (targetUserId && !matchedUser) { - getUser(targetUserId).then((fetchedUser) => { - if (fetchedUser) { - queryClient.setQueryData(['users-directory'], (old = []) => { - if (old.some((u) => u.id === fetchedUser.id)) return old; - return [fetchedUser, ...old]; - }); - if (fetchedUser.idNumber && !orgForm.getValues('idNumber')) { - orgForm.setValue('idNumber', fetchedUser.idNumber, { shouldValidate: true }); - } - if (!orgForm.getValues('organisationUser')) { - orgForm.setValue('organisationUser', fetchedUser.id, { shouldValidate: true }); - } - } - }).catch(() => {}); - } }; const openCreatePortfolio = () => { @@ -1477,66 +1562,6 @@ export const GovernancePage: React.FC = () => {
-
-
-
- - -
- - {orgForm.formState.errors.organisationUser && ( -

{orgForm.formState.errors.organisationUser.message}

- )} -
- -
-
- - -
- - {orgForm.formState.errors.idNumber && ( -

{orgForm.formState.errors.idNumber.message}

- )} -
-
-
@@ -1561,6 +1586,164 @@ export const GovernancePage: React.FC = () => { )}
+
+
+
+ + +
+ {!editingOrg && ( + + )} +
+ + {orgForm.formState.errors.organisationUser && ( +

{orgForm.formState.errors.organisationUser.message}

+ )} + {executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && ( +

+ No Executive or Manager user found. Create one below to continue. +

+ )} +
+
+ + {/* Inline Collapsible Subform to Create Executive User */} + {isQuickAddUserOpen && !editingOrg && ( +
+
+ + + Quick Create Executive / Manager User + + Mapped to tenant automatically +
+ + {quickUserError && ( +
+ {quickUserError} +
+ )} + +
+
+ + setQuickUserEmail(e.target.value)} + placeholder="executive@organisation.org.za" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserName(e.target.value)} + placeholder="e.g. Sipho Ndlovu" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserPhone(e.target.value)} + placeholder="+27 82 123 4567" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setQuickUserIdNumber(e.target.value)} + placeholder="13-digit ID or Passport" + className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + +
+
+ +
+ + +
+
+ )} + {editingOrg && (
@@ -1586,7 +1769,6 @@ export const GovernancePage: 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/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index 657e352..50087e9 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -227,7 +227,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, }); diff --git a/PostFundManagement.ConsoleUI/src/types.ts b/PostFundManagement.ConsoleUI/src/types.ts index abd6786..a3cde2c 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; 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.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 +} -- 2.47.3 From dee90983790bd43feadc6ded8bd85c997539c0a1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 20 Oct 2026 17:56:04 +0200 Subject: [PATCH 3/4] Fixed tenant management --- .../Governance/GetTenantUsersEndpoint.cs | 34 +++ .../Services/IGovernanceService.cs | 5 +- .../Governance/GovernanceCacheService.cs | 5 +- .../Services/Governance/GovernanceService.cs | 22 +- PostFundManagement.ConsoleUI/src/App.tsx | 10 +- PostFundManagement.ConsoleUI/src/auth/rbac.ts | 26 +++ .../src/components/layout/AppLayout.tsx | 4 +- .../components/users/UserSecurityModal.tsx | 194 +++++++++++------- .../src/pages/TenantManagementPage.tsx | 157 +++++++------- .../src/services/api.ts | 21 ++ PostFundManagement.ConsoleUI/src/types.ts | 12 ++ .../Entities/TenantOrganisation.cs | 4 +- .../Entities/TenantUser.cs | 4 +- .../Extensions/Mappers.cs | 6 +- .../Models/TenantOrganisation.cs | 4 +- .../Models/TenantUser.cs | 4 +- 16 files changed, 344 insertions(+), 168 deletions(-) create mode 100644 PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs diff --git a/PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs new file mode 100644 index 0000000..df7c401 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Governance/GetTenantUsersEndpoint.cs @@ -0,0 +1,34 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Models; + +namespace PostFundManagement.Api.Endpoints.Governance; + +[ApiVersionTarget(1)] +public sealed class GetTenantUsersEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/governance/tenants/{tenantIdHash}/users", async (IGovernanceService service, string tenantIdHash, + CancellationToken cancellationToken = default) => + await service.GetTenantUsersAsync(tenantIdHash, cancellationToken) switch + { + { IsSuccess: true, Value: var tenantUsers } => Results.Ok(tenantUsers), + var r when r.HasError() => Results.Forbid(), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Gets users created and managed by one tenant") + .RequireAuthorization(Constants.MainScope) + .WithName(typeof(GetTenantUsersEndpoint).ToEndpointName("Retrieval")) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Governance); + } +} + diff --git a/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs b/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs index f82d6fa..8bbb97c 100644 --- a/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IGovernanceService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Services.Governance; +using PostFundManagement.Application.Services.Governance; using PostFundManagement.Domain; using PostFundManagement.Domain.Attributes; using PostFundManagement.Domain.Models; @@ -95,4 +95,7 @@ public interface IGovernanceService [Log] Task> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default); + + [Log] + Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default); } diff --git a/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs b/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs index 8deb7fd..fa5d154 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceCacheService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -288,6 +288,9 @@ public sealed class GovernanceCacheService(GovernanceService service, IDistribut return Result.Ok(cached.FromJson()); })); + public async Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) => + await service.GetTenantUsersAsync(tenantIdHash, cancellationToken); + public async Task> GetTenantsAsync(CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.Application/Services/Governance/GovernanceService.cs b/PostFundManagement.Application/Services/Governance/GovernanceService.cs index 265a2bf..1ddfdab 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceService.cs @@ -1,4 +1,4 @@ -using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Extensions; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; @@ -452,6 +452,26 @@ public sealed class GovernanceService(IDbContextFactory co return Result.Ok(tenantOrganisations?.Select(i => i.Map()).ToArray() ?? []); })); + public async Task> GetTenantUsersAsync(string tenantIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + var decodeResult = hashService.DecodeLongIdHash(tenantIdHash); + + if (decodeResult.IsFailed) + return Result.Fail(IdDecodeError.Create() + .CausedBy(decodeResult.Errors)); + + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + + var tenantUsers = await context.TenantUsers.AsNoTracking() + .Include(i => i.User) + .Include(i => i.Tenant) + .Where(f => f.TenantId == decodeResult.Value) + .ToListAsync(cancellationToken); + + return Result.Ok(tenantUsers?.Select(i => i.Map()).ToArray() ?? []); + })); + public async Task> GetTenantOrganisationAsync(string tenantOrganisationIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.ConsoleUI/src/App.tsx b/PostFundManagement.ConsoleUI/src/App.tsx index df40f9d..5fa292b 100644 --- a/PostFundManagement.ConsoleUI/src/App.tsx +++ b/PostFundManagement.ConsoleUI/src/App.tsx @@ -3,7 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './lib/queryClient'; import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider'; -import { ClaimProtectedRoute, useRbac } from './auth/rbac'; +import { ClaimProtectedRoute, TenantManagementProtectedRoute, useRbac } from './auth/rbac'; import { ToastProvider } from './components/ui/Toast'; import { AppLayout } from './components/layout/AppLayout'; @@ -151,17 +151,17 @@ export default function App() { + - + } /> + - + } /> } /> diff --git a/PostFundManagement.ConsoleUI/src/auth/rbac.ts b/PostFundManagement.ConsoleUI/src/auth/rbac.ts index ea24443..e403991 100644 --- a/PostFundManagement.ConsoleUI/src/auth/rbac.ts +++ b/PostFundManagement.ConsoleUI/src/auth/rbac.ts @@ -354,3 +354,29 @@ export const ClaimProtectedRoute: React.FC<{ return React.createElement(React.Fragment, null, children); }; + +/** + * Route protection for Tenant Management (Solution Owners [Tenant 1 + Admin] OR Tenant Self-Service users). + */ +export const TenantManagementProtectedRoute: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const { canManageTenants, isLoading } = useAuth(); + const { canView, canAccess } = useRbac(); + + if (isLoading) { + return null; + } + + const hasAccess = + canManageTenants || + canView('tenant_management') || + canView('tenant_self_service') || + canAccess('tenant_self_service'); + + if (!hasAccess) { + return React.createElement(Navigate, { to: '/dashboard', replace: true }); + } + + return React.createElement(React.Fragment, null, children); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index 519851f..80b0c2d 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -138,7 +138,7 @@ export const AppLayout: React.FC = () => { const visibleNavItems = navItems .filter((item) => { if (item.path === '/tenants') { - return canManageTenants || canView('tenant_management'); + return canManageTenants || canView('tenant_management') || canView('tenant_self_service'); } if (item.path === '/settings') { return ( @@ -456,7 +456,7 @@ export const AppLayout: React.FC = () => {
Tenant: - {canManageTenants ? ( + {canManageTenants || canView('tenant_management') || canView('tenant_self_service') ? ( = ({ const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac(); const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service'); - const canManageRolesAndClaims = canApprove('settings_users') || canApprove('tenant_management') || isUnrestricted; - const canManageSessions = canApprove('settings_users') || canApprove('tenant_management') || hasTenantSelfHelpAccess || isUnrestricted; - const canChangeStatus = canAdd('settings_users') || canAdd('tenant_management') || canApprove('settings_users') || isUnrestricted; + const canManageRolesAndClaims = + canApprove('settings_users') || + canApprove('tenant_management') || + canApprove('tenant_self_service') || + canAdd('tenant_self_service') || + isUnrestricted; + const canManageSessions = + canApprove('settings_users') || + canApprove('tenant_management') || + hasTenantSelfHelpAccess || + isUnrestricted; + const canChangeStatus = + canAdd('settings_users') || + canAdd('tenant_management') || + canAdd('tenant_self_service') || + canApprove('settings_users') || + isUnrestricted; const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview'); @@ -105,8 +119,11 @@ export const UserSecurityModal: React.FC = ({ // Role Mutations const assignRoleMutation = useMutation({ mutationFn: (roleId: string) => { - const selectedRole = systemRoles.find((r) => r.id === roleId); - if (selectedRole && (selectedRole.name.toLowerCase() === 'admin' || selectedRole.id === '1') && hasTenantSelfHelpAccess) { + const selectedRole = (Array.isArray(systemRoles) ? systemRoles : []).find( + (r) => r && (String(r.id) === String(roleId) || String((r as any).Id) === String(roleId)) + ); + const roleName = selectedRole?.name || (selectedRole as any)?.Name || ''; + if (selectedRole && (roleName.toLowerCase() === 'admin' || String(selectedRole.id) === '1') && hasTenantSelfHelpAccess) { throw new Error('Security policy: Users with tenant self-help access cannot assign Admin roles.'); } return assignUserRole(userId, roleId); @@ -192,11 +209,16 @@ export const UserSecurityModal: React.FC = ({ if (!user) return null; const effectiveUser = freshUser || user; - const decodedId = effectiveUser.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; - const assignedRoleIds = new Set(userRoles.map((r) => r.id)); - const availableRoles = systemRoles.filter((r) => { - if (assignedRoleIds.has(r.id)) return false; - if (hasTenantSelfHelpAccess && (r.name.toLowerCase() === 'admin' || r.id === '1')) { + const decodedId = effectiveUser?.idNumber ? decodeIdNumberHex(effectiveUser.idNumber) : null; + const assignedRoleIds = new Set( + (Array.isArray(userRoles) ? userRoles : []).map((r) => r?.id || (r as any)?.Id).filter(Boolean) + ); + const availableRoles = (Array.isArray(systemRoles) ? systemRoles : []).filter((r) => { + if (!r) return false; + const rId = r.id || (r as any).Id; + const rName = r.name || (r as any).Name || ''; + if (rId && assignedRoleIds.has(rId)) return false; + if (hasTenantSelfHelpAccess && (rName.toLowerCase() === 'admin' || String(rId) === '1')) { return false; } return true; @@ -204,21 +226,28 @@ export const UserSecurityModal: React.FC = ({ const assignableClaims = React.useMemo(() => { const claimMap = new Map(); - systemClaims.forEach((c) => { - const type = c.claimType || c.type; - if (type) { - if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { + if (Array.isArray(systemClaims)) { + systemClaims.forEach((c) => { + if (!c) return; + const type = c.claimType || c.type; + if (type && typeof type === 'string') { + if (hasTenantSelfHelpAccess && (type.toLowerCase() === 'unrestricted' || type.toLowerCase() === 'admin')) { + return; + } + claimMap.set(type, { claim: type, label: type }); + } + }); + } + if (Array.isArray(ROLE_CLAIM_DEFINITIONS)) { + ROLE_CLAIM_DEFINITIONS.forEach((def) => { + if (!def || !def.claim) return; + const claimStr = String(def.claim); + if (hasTenantSelfHelpAccess && (claimStr.toLowerCase() === 'unrestricted' || claimStr.toLowerCase() === 'admin')) { return; } - claimMap.set(type, { claim: type, label: type }); - } - }); - ROLE_CLAIM_DEFINITIONS.forEach((def) => { - if (hasTenantSelfHelpAccess && (def.claim === 'unrestricted' || (def.claim as string) === 'admin')) { - return; - } - claimMap.set(def.claim, { claim: def.claim, label: `${def.claim} (${def.label})` }); - }); + claimMap.set(claimStr, { claim: claimStr, label: `${claimStr} (${def.label || claimStr})` }); + }); + } return Array.from(claimMap.values()); }, [systemClaims, hasTenantSelfHelpAccess]); @@ -325,10 +354,10 @@ export const UserSecurityModal: React.FC = ({ - {getActivityStatusBadge(effectiveUser.status).label} + {(getActivityStatusBadge(effectiveUser.status) || {}).label || 'Active'}
@@ -336,8 +365,8 @@ export const UserSecurityModal: React.FC = ({ @@ -345,8 +374,8 @@ export const UserSecurityModal: React.FC = ({ @@ -365,8 +394,8 @@ export const UserSecurityModal: React.FC = ({ ` controls is disabled with contextual labels: + ```tsx + + ``` +2. **Action Buttons**: Fast-track "Approve" buttons (e.g. Budget Approval, Disbursement Authorisation, Evidence Review) are hidden or accompanied by a Maker-Checker indicator badge when viewed by the creator. +3. **Export Controls**: `` enforces domain claim checks explicitly (`claim="grant_financials"`), rejecting unauthenticated exports. + +--- + +## 5. Caching Strategy, Mutation Versioning & Performance Optimization + +To achieve sub-50ms page transitions while guaranteeing data freshness across mutations in a distributed architecture, PFM employs a multi-tiered caching topology. + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ REACT QUERY CACHE (In-Memory Browser Tier) │ +│ staleTime: 2m (dynamic) | 5m (taxonomy) | gcTime: 10m │ +└───────────────────────────────────┬────────────────────────────────────┘ + │ Cache Miss / Background Refetch +┌───────────────────────────────────▼────────────────────────────────────┐ +│ AXIOS CLIENT INTERCEPTOR & MUTATION VERSIONING (_v) │ +│ Reads/Browsing: attaches active `_v` epoch token │ +│ Mutations (POST/PUT/PATCH/DELETE): auto-bumps epoch -> new `_v` token │ +└───────────────────────────────────┬────────────────────────────────────┘ + │ HTTP GET /v1/api/*?_v= +┌───────────────────────────────────▼────────────────────────────────────┐ +│ ASP.NET CORE OUTPUTCACHE (Redis / Memory Tier) │ +│ Base Policy: 2-minute expiration partitioned by URI + QueryString │ +│ Hits same `_v` -> 2ms response | New `_v` -> DB query & cache refresh │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.1 The OutputCache Stale Data Challenge +ASP.NET Core Minimal APIs in `PostFundManagement.Api` attach `.CacheOutput()` to GET endpoints, using a 2-minute base sliding expiration backed by Redis / Memory cache. + +Because OutputCache keys are generated from the HTTP Method, Host, Path, and **Query String**, a naive client refetch after a mutation (e.g. `queryClient.invalidateQueries({ queryKey: ['projects'] })`) calls `GET /v1/api/projects`. OutputCache intercepts this identical URI, returning the pre-mutation response cached earlier. The updated project status or approved milestone does not reflect in the UI until the 2-minute cache expires. + +### 5.2 Client-Side Mutation Epoch Versioning +PFM solves this without modifying backend endpoints via **Client-Side Mutation Versioning**: +1. **Epoch Tracking (`activeMutationEpoch`)**: `httpClient.ts` tracks an active mutation timestamp initialized from `sessionStorage` or `Date.now()`. +2. **Automatic Mutation Interceptor**: Every successful mutating HTTP response (`POST`, `PUT`, `PATCH`, `DELETE` with HTTP 2xx) triggers `bumpMutationEpoch()`. +3. **Query Parameter Injection**: Outgoing `GET` requests to internal APIs automatically append `_v: getMutationEpoch()`. +4. **Instant Invalidation**: When a mutation succeeds, the epoch is bumped. Subsequent React Query refetches send the new `_v`, producing an immediate **cache miss** in ASP.NET Core OutputCache. The backend queries PostgreSQL, returns fresh data, and caches the new state under the new `_v` key. +5. **Cache Hit Snappiness**: Navigations between pages while no mutations have occurred reuse the stable `_v`, yielding sub-5ms responses from Redis/Memory OutputCache and 0ms from React Query in-memory cache. +6. **Explicit Freshness (`_t`)**: When `Cache-Control: no-cache` is specified or during explicit refresh operations, a unique timestamp `_t` is attached to force a guaranteed fresh fetch. + +### 5.3 RBAC Startup Pipeline Acceleration +Following the introduction of fine-grained RBAC and Maker-Checker checks, startup performance was streamlined to eliminate sequential network waterfalls: +* **Cache-First Role Claims**: `fetchAndCacheSystemRoles` and `fetchAndCacheRoleClaims` check in-memory and `localStorage` caches *before* dispatching network calls. +* **Parallel User Resolution**: `getUserRolesFromApi` and `getUserClaimsFromApi` are resolved concurrently using `Promise.allSettled`. +* **Parallel Claim Resolution**: Claims across all assigned roles are retrieved concurrently rather than in serial loops. +* **Immediate Non-Blocking Session Render**: If valid session tokens and user profiles exist in `localStorage`, `AuthProvider` immediately unblocks UI rendering (`isLoading: false`) and runs tenant lockout checks, account synchronization, and claim revalidation asynchronously in the background. +* **In-Memory User Sync & Lockout Caching**: `isUserSyncedInBrowser` and `verifyTenantLockout` maintain in-memory references to eliminate synchronous `JSON.parse` operations from the request pipeline. + + diff --git a/GEMINI.md b/GEMINI.md index 9eccc89..55a6552 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -116,7 +116,7 @@ PostFundManagement/ ### 2.3 Infrastructure, Storage & Security Topology * **Database**: PostgreSQL 14+ (Separate connection strings for primary transactional store `pfm` and scheduler store `pfm-scheduler`). * **Object / Evidence Store**: S3-compatible storage (e.g., Garage S3 / AWS S3) with pre-signed upload URLs and CDN acceleration. -* **Cache**: Redis (`StackExchange.Redis` + ASP.NET HybridCache) for dashboard KPIs and taxonomy lookup caching. +* **Cache**: Redis (`StackExchange.Redis` + ASP.NET Core `OutputCaching` 2-minute base policy on Minimal API GET endpoints + HybridCache). Paired with TanStack React Query in `ConsoleUI` and Axios mutation versioning (`_v` parameter) to guarantee instant reads and zero stale data after mutations. * **Authentication / STS**: Duende IdentityServer / Skoruba Administration (`https://sts.security.khongisa.co.za`). * **Observability**: OpenTelemetry exporters streaming traces, metrics, and logs to Aspire Dashboard / OTLP collectors. @@ -554,6 +554,19 @@ The following baseline mappings define the claims assigned to system roles: * Endpoints inspect JWT claims (`role_claim:` or array claims) to enforce authorization before executing command/query handlers. * Violations immediately return `403 Forbidden` and log an audit security event (`NFR-004`, `AT-13`). +### 10.5 Maker-Checker Segregation of Duties Pattern + +In addition to RBAC role and claim constraints, the platform enforces the **Maker-Checker Pattern** across all operational lifecycle modules: + +* **Separation of Duty Rule**: **The user who creates or drafts a record (the "Maker") is strictly barred from verifying or approving that record (the "Checker"), regardless of their role or administrative privileges.** +* **Attribution**: Every domain entity immutably captures the creator's user identifier in `createdBy` (Guid/Subject ID resolved from `context.GetUserId()`). +* **Evaluation Formula**: + $$\text{Can Approve} = \text{Has 'update' claim permission} \land (\text{Authenticated User ID} \neq \text{Record createdBy})$$ +* **Client UI Enforcement**: + * Components use `const { canApproveRecord, isMaker, getApprovalRestrictionReason } = useRbac()`. + * Status transition dropdowns disable the `Approved` / `Active` option when `!canApproveRecord(claim, record.createdBy)` with contextual indicator `(Maker-Checker: Cannot approve own record)`. + * Fast-track approval buttons (budget lines, disbursement tranches, evidence submissions, change requests) are hidden or marked with a Maker-Checker warning badge when inspected by the creator. + --- ## 11. Functional API Surface & Endpoint Catalog diff --git a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx index ba4d1fa..7b77dda 100644 --- a/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx +++ b/PostFundManagement.ConsoleUI/src/auth/AuthProvider.tsx @@ -581,62 +581,65 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } catch {} } - // Verify tenant lockout at the instance tenant and tenant_id claims are retrieved - const tenantLockoutResult = await verifyTenantLockout( - storedTenantId ?? tenantClaimValues.tenantId, - storedTenantClaim - ); - - if (!tenantLockoutResult.isActive) { - setIsPendingActivation(true); - setIsSynced(false); - setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found'); - setIsLoading(false); - return; - } - - if (tenantLockoutResult.tenant?.name) { - if (resolvedUser) { - resolvedUser.tenant = tenantLockoutResult.tenant.name; - setUser({ ...resolvedUser }); - localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); - } - setTenantName(tenantLockoutResult.tenant.name); - } else if (storedTenantClaim) { + if (storedTenantClaim) { setTenantName(storedTenantClaim); } - // Check if session is already marked pending activation or needs sync check - const wasPending = isActivationPending(); - setIsPendingActivation(wasPending); - setActivationError(getActivationPendingReason()); + // Initial fast unblock: valid persisted session found, unblock UI immediately + setIsLoading(false); - try { - // Verify sync with browser cache priority (hotspot prevention) - const syncResult = await syncUserAccount( - storedSub || '', - storedToken, - resolvedUser || undefined, - false - ); + // Asynchronous background revalidation (Tenant Lockout, User Sync, Role Claims) + (async () => { + try { + const tenantLockoutResult = await verifyTenantLockout( + storedTenantId ?? tenantClaimValues.tenantId, + storedTenantClaim + ); - if (syncResult.isUnauthorized) { - // HTTP 401: Normal process of routing user to landing page - await logout(); - return; - } + if (!tenantLockoutResult.isActive) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(tenantLockoutResult.errorMessage || 'Tenant is inactive or not found'); + return; + } - if (syncResult.isActivationPending) { - setIsPendingActivation(true); - setIsSynced(false); - setActivationError(syncResult.errorMessage || null); - } else if (syncResult.success) { - setIsPendingActivation(false); - setIsSynced(true); - setActivationError(null); + if (tenantLockoutResult.tenant?.name) { + if (resolvedUser) { + resolvedUser.tenant = tenantLockoutResult.tenant.name; + setUser({ ...resolvedUser }); + localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser)); + } + setTenantName(tenantLockoutResult.tenant.name); + } + + const wasPending = isActivationPending(); + setIsPendingActivation(wasPending); + setActivationError(getActivationPendingReason()); + + const syncResult = await syncUserAccount( + storedSub || '', + storedToken, + resolvedUser || undefined, + false + ); + + if (syncResult.isUnauthorized) { + await logout(); + return; + } + + if (syncResult.isActivationPending) { + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncResult.errorMessage || null); + return; + } + + if (syncResult.success) { + setIsPendingActivation(false); + setIsSynced(true); + setActivationError(null); - // Retrieve user roles, role claims, and user claims (Requirement 2 & 3 & 7) - try { const resolved = await resolveUserRolesAndClaims( storedSub || '', parsedClaims, @@ -657,17 +660,19 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } setClaims({ ...resolved.claims }); localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(resolved.claims)); - } catch {} + } + } catch (syncErr: any) { + if (syncErr?.response?.status === 401) { + await logout(); + return; + } + setIsPendingActivation(true); + setIsSynced(false); + setActivationError(syncErr?.message || 'Account activation pending'); } - } catch (syncErr: any) { - if (syncErr?.response?.status === 401) { - await logout(); - return; - } - setIsPendingActivation(true); - setIsSynced(false); - setActivationError(syncErr?.message || 'Account activation pending'); - } + })(); + + return; } } catch (err: unknown) { console.warn('OIDC initialization notice:', err); diff --git a/PostFundManagement.ConsoleUI/src/auth/rbac.ts b/PostFundManagement.ConsoleUI/src/auth/rbac.ts index e403991..4fbadf6 100644 --- a/PostFundManagement.ConsoleUI/src/auth/rbac.ts +++ b/PostFundManagement.ConsoleUI/src/auth/rbac.ts @@ -142,12 +142,14 @@ export function resolveClaimActionSet( actions.add('add'); actions.add('update'); actions.add('export'); - } else if (val === 'view') { + } else if (val === 'view' || val === 'read') { actions.add('view'); - } else if (val === 'add') { + } else if (val === 'add' || val === 'edit' || val === 'write' || val === 'create') { + // 'add' encompasses create and non-approval edits actions.add('view'); actions.add('add'); - } else if (val === 'update') { + } else if (val === 'update' || val === 'approve' || val === 'verify') { + // 'update' strictly grants approval capability (+ view and add) actions.add('view'); actions.add('add'); actions.add('update'); @@ -173,10 +175,13 @@ export function resolveClaimActionSet( actions.add('update'); actions.add('export'); } else if (role === 'supervisor') { + // Supervisor performs operational review and verification/approval of milestones & evidence actions.add('view'); actions.add('add'); + actions.add('update'); actions.add('export'); } else if (role === 'contributor') { + // Contributor is strictly working-level execution: creating activities, submitting evidence, etc. (NO approve/update) actions.add('view'); actions.add('add'); } else { @@ -294,11 +299,36 @@ export function evaluateUserPermission( return false; } +/** + * Evaluates Maker-Checker separation of duties constraint. + * In PFM governance, the user who created (or initiated) a record ("Maker") + * is strictly barred from approving ("Checking") their own record. + * + * @param createdBy The user identifier of the record creator (Guid, sub, email) + * @param currentUserId Current logged-in user ID (user.id or sub) + * @param currentUserSub Current logged-in user subject claim + * @param currentUserEmail Current logged-in user email + * @returns boolean true if user is the creator (Maker), false otherwise + */ +export function isRecordMaker( + createdBy?: string | null, + currentUserId?: string | null, + currentUserSub?: string | null, + currentUserEmail?: string | null +): boolean { + if (!createdBy) return false; + const creator = createdBy.trim().toLowerCase(); + if (currentUserId && creator === currentUserId.trim().toLowerCase()) return true; + if (currentUserSub && creator === currentUserSub.trim().toLowerCase()) return true; + if (currentUserEmail && creator === currentUserEmail.trim().toLowerCase()) return true; + return false; +} + /** * React hook exposing RBAC evaluation helpers for components. */ export function useRbac() { - const { roles, claims } = useAuth(); + const { roles, claims, user, sub } = useAuth(); const permissions = useMemo(() => { const isUnrestricted = @@ -315,6 +345,22 @@ export function useRbac() { const canApprove = (claim: RoleClaim): boolean => canAccess(claim, 'update'); const canExport = (claim: RoleClaim): boolean => canAccess(claim, 'export'); + const isMaker = (createdBy?: string | null): boolean => { + return isRecordMaker(createdBy, user?.id, sub, user?.email); + }; + + const canApproveRecord = (claim: RoleClaim, createdBy?: string | null): boolean => { + if (!canApprove(claim)) return false; + // Maker-Checker policy: record creator cannot approve their own record + return !isMaker(createdBy); + }; + + const getApprovalRestrictionReason = (claim: RoleClaim, createdBy?: string | null): string | null => { + if (!canApprove(claim)) return 'Requires update/approval permission'; + if (isMaker(createdBy)) return 'Maker-Checker policy: Record creators cannot approve their own records'; + return null; + }; + return { isUnrestricted, canAccess, @@ -322,8 +368,11 @@ export function useRbac() { canAdd, canApprove, canExport, + isMaker, + canApproveRecord, + getApprovalRestrictionReason, }; - }, [roles, claims]); + }, [roles, claims, user, sub]); return permissions; } diff --git a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx index a66e72f..0a9e728 100644 --- a/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx +++ b/PostFundManagement.ConsoleUI/src/components/governance/InstructionsSection.tsx @@ -351,6 +351,7 @@ export const FundingInstrumentsSection: React.FC = ({ = ({ }) => { const queryClient = useQueryClient(); const { success, error: toastError, info } = useToast(); + const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac(); // Component state const [internalCreateModalOpen, setInternalCreateModalOpen] = useState(false); @@ -602,6 +604,7 @@ export const ContractsManagement: React.FC = ({ = ({ data={filteredContracts} /> - + {canAdd('grant_financials') && ( + + )}
@@ -1224,17 +1229,34 @@ export const ContractsManagement: React.FC = ({ - + {(() => { + const canApproveContract = canApproveRecord('grant_financials', contractToUpdate?.createdBy); + const isSelf = isMaker(contractToUpdate?.createdBy); + + return ( + + ); + })()}
diff --git a/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx b/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx index db08d97..0bba4cd 100644 --- a/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx +++ b/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx @@ -87,7 +87,7 @@ export const EvidenceReviewQueue: React.FC = ({ }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); - const { canAdd, canApprove, canExport } = useRbac(); + const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac(); // Filter States: default to initialProjectId if given, otherwise when projects load default to first project const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); @@ -824,7 +824,7 @@ export const EvidenceReviewQueue: React.FC = ({ )} - {canApprove('evidence_risks') && ( + {canApproveRecord('evidence_risks', ev.createdBy) && ( )} + {canApprove('evidence_risks') && isMaker(ev.createdBy) && ( + + Maker-Checker (Creator) + + )} )} - {canApprove('evidence_risks') && ( + {canApproveRecord('evidence_risks', inspectEvidence.createdBy) && ( )} + {canApprove('evidence_risks') && isMaker(inspectEvidence.createdBy) && ( + + + Maker-Checker Policy: As the creator of this submission, you cannot approve it. + + )}
diff --git a/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx b/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx index 020bd0c..400595a 100644 --- a/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx +++ b/PostFundManagement.ConsoleUI/src/components/projects/ProjectMilestonesModal.tsx @@ -25,6 +25,7 @@ import { updateMilestone, } from '../../services/api'; import { Project, Milestone, Priority, ApprovalStatus } from '../../types'; +import { useRbac } from '../../auth/rbac'; import { formatDate, getApprovalStatusBadge, @@ -55,6 +56,7 @@ export const ProjectMilestonesModal: React.FC = ({ }) => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const { canAdd, canApprove, canApproveRecord, isMaker } = useRbac(); const [isFormOpen, setIsFormOpen] = useState(false); const [editingMilestone, setEditingMilestone] = useState(null); @@ -175,7 +177,7 @@ export const ProjectMilestonesModal: React.FC = ({
- {!isFormOpen && ( + {!isFormOpen && canAdd('projects_milestones') && (
)} diff --git a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx index 4cc10b4..8fcd362 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ChangeManagementPage.tsx @@ -110,7 +110,7 @@ type ResolveChangeFormValues = z.infer; export const ChangeManagementPage: 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 [activeTab, setActiveTab] = useState<'issues' | 'changes'>('issues'); @@ -1022,7 +1022,7 @@ export const ChangeManagementPage: React.FC = () => { ) : ( <> - {canApprove('change_management') && ( + {canApproveRecord('change_management', cr.createdBy) && ( )} + {canApprove('change_management') && isMaker(cr.createdBy) && ( + + Maker-Checker + + )} {canAdd('change_management') && ( - {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 -- 2.47.3