import axios, { AxiosError } from 'axios'; import { apiClient } from './httpClient'; import { toolsService } from '../utils/hashids'; import { notifyTenantManagersAndExecutivesOnRecordStatusChange, notifyInviteApproved, notifyNewUserCreated, isApprovedOrActiveStatus, } from './notificationRulesService'; export { notifyTenantManagersAndExecutivesOnRecordStatusChange, notifyInviteApproved, notifyNewUserCreated, isApprovedOrActiveStatus, }; import { getCurrentUserSub, getCurrentUserTenantId, getCurrentUserHashedTenantId, } from '../auth/AuthProvider'; export { getCurrentUserTenantId, getCurrentUserHashedTenantId }; import { Dashboard, Organisation, CreateOrganisationRequest, UpdateOrganisationRequest, Portfolio, CreatePortfolioRequest, UpdatePortfolioRequest, Programme, CreateProgrammeRequest, UpdateProgrammeRequest, Instrument, CreateInstrumentRequest, UpdateInstrumentRequest, Instruction, CreateInstructionRequest, UpdateInstructionRequest, Project, CreateProjectRequest, UpdateProjectRequest, UpdateProjectStatusRequest, Activity, CreateActivityRequest, UpdateActivityRequest, Milestone, CreateMilestoneRequest, UpdateMilestoneRequest, Award, CreateAwardRequest, UpdateAwardRequest, Contract, ContractStatus, CreateContractRequest, UpdateContractRequest, ExecuteContractRequest, Budget, CreateBudgetRequest, UpdateBudgetRequest, Disbursement, CreateDisbursementRequest, UpdateDisbursementRequest, Indicator, CreateIndicatorRequest, Evidence, UpdateEvidenceRequest, Risk, CreateRiskRequest, UpdateRiskRequest, User, UserFilterRequest, Invite, InviteRequest, InviteUserRequest, AuditLog, ProblemDetails, ActivityStatus, ApprovalStatus, Tenant, TenantOrganisation, TenantUser, CreateTenantRequest, UpdateTenantRequest, Beneficiary, CreateBeneficiaryRequest, UpdateBeneficiaryRequest, Outcome, CreateOutcomeRequest, UpdateOutcomeRequest, ChangeRequest, CreateChangeRequest, UpdateChangeRequest, ComplianceItem, CreateComplianceItemRequest, UpdateComplianceItemRequest, Issue, CreateIssueRequest, UpdateIssueRequest, SiteVisit, CreateSiteVisitRequest, UpdateSiteVisitRequest, Rule, CreateRuleRequest, UpdateRuleRequest, Pagination, Notification, NotificationPlatform, NotificationStatus, Role, Claim, CreateUserRequest, AddUserClaimRequest, CreateNotificationRequest, UpdateNotificationRequest, } from '../types'; import { encodeIdNumberHex, encodeEntityId, toSafeInt64String, } from '../utils/hashids'; import { parseFriendlyError, FriendlyError, inspectErrorResponseObject, parseCommaDelimitedErrors, } from '../utils/apiErrorParser'; import { registerUserEmail, registerUserEmails, getUserIdByEmail } from '../lib/formatters'; // Helper to extract ProblemDetails from Axios error or unknown error export function parseApiError(error: unknown): ProblemDetails { const returnObj = inspectErrorResponseObject(error); if (returnObj) { return { type: returnObj.type || 'https://tools.ietf.org/html/rfc9110#section-15.5.1', title: returnObj.title || 'Error', status: returnObj.status || (axios.isAxiosError(error) ? error.response?.status : 400), detail: returnObj.detail || null, instance: returnObj.instance || null, }; } const friendly = parseFriendlyError(error); return { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1', title: friendly.title, detail: friendly.message, status: friendly.statusCode || 500, }; } export { parseFriendlyError, inspectErrorResponseObject, parseCommaDelimitedErrors }; export type { FriendlyError }; // ========================================== // 1. Executive Dashboard APIs // ========================================== export const getDashboard = async (): Promise => { const res = await apiClient.get('/v1/api/governance/dashboard'); return res.data; }; // ========================================== // 2. Governance & Structure APIs // ========================================== /** * Ensures an ID is converted to a canonical int64 integer number. * Decodes HashIDs if a HashID string is passed, ensuring request body schemas * (pattern: ^-?(?:0|[1-9]\d*)$) receive numeric integers, never raw HashIDs. */ export const toIntegerId = (id: number | string | null | undefined): number => { return toolsService.decodeToNumberSync(id); }; export const toNullableIntegerId = (id: number | string | null | undefined): number | null => { if (id === null || id === undefined || id === '') return null; return toolsService.decodeToNumberSync(id); }; /** * Normalizes an Organisation object from backend responses. * Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive). */ export const normalizeOrganisation = (raw: any): Organisation => { if (!raw) return raw; const status = raw.status ?? raw.Status ?? raw.activityStatus ?? raw.ActivityStatus ?? raw.approvalStatus ?? raw.ApprovalStatus ?? raw.statusId ?? raw.StatusId ?? raw.state ?? raw.State ?? (raw.isActive !== undefined ? (raw.isActive ? 1 : 2) : undefined) ?? (raw.IsActive !== undefined ? (raw.IsActive ? 1 : 2) : undefined) ?? ActivityStatus.Active; return { ...raw, id: raw.id ?? raw.Id ?? raw.organisationId ?? raw.OrganisationId, tenantId: raw.tenantId ?? raw.TenantId ?? raw.tenant_id ?? null, name: raw.name ?? raw.Name ?? null, registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null, email: raw.email ?? raw.Email ?? null, userId: raw.userId ?? raw.UserId ?? raw.organisationUser ?? raw.OrganisationUser ?? raw.user_id ?? raw.linkedUser ?? raw.LinkedUser ?? null, idNumber: raw.idNumber ?? raw.IdNumber ?? raw.id_number ?? null, type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5, status, createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(), createdBy: raw.createdBy ?? raw.CreatedBy ?? '', }; }; export const getOrganisations = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/governance/organisations', { params: { page, pageSize }, }); const list = res.data || []; return list.map(normalizeOrganisation); }; export const getOrganisation = async (organisationId?: string | number): Promise => { const targetOrgId = organisationId !== undefined && organisationId !== '' && organisationId !== null ? organisationId : getCurrentUserTenantId(); if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') { throw new Error('Organisation ID is required'); } const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId); const res = await apiClient.get(`/v1/api/governance/organisations/${hashedOrgId}`); return normalizeOrganisation(res.data); }; export const createOrganisation = async (payload: CreateOrganisationRequest): Promise => { const res = await apiClient.post('/v1/api/governance/organisations', { name: payload.name, registrationNo: payload.registrationNo, email: payload.email, idNumber: payload.idNumber || null, organisationUser: payload.organisationUser, type: payload.type !== undefined ? Number(payload.type) : 5, }); return res.data; }; export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise => { const orgStatus = payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active; const res = await apiClient.put('/v1/api/governance/organisations', { organisationId: toIntegerId(payload.organisationId), name: payload.name, registrationNo: payload.registrationNo, email: payload.email, idNumber: payload.idNumber || null, organisationUser: payload.organisationUser, type: Number(payload.type), status: orgStatus, }); if (isApprovedOrActiveStatus(orgStatus)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Organisation', recordId: payload.organisationId, newStatus: orgStatus, title: payload.name, }).catch(console.warn); } return res.data; }; export const getOrganisationPortfolios = async (organisationId?: string | number): Promise => { const targetOrgId = organisationId !== undefined && organisationId !== '' && organisationId !== null ? organisationId : getCurrentUserTenantId(); if (targetOrgId === undefined || targetOrgId === null || targetOrgId === '') { return []; } const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId); try { const res = await apiClient.get(`/v1/api/governance/organisations/${hashedOrgId}/portfolio`); return res.data || []; } catch (err) { // If hashed ID fails or is not recognized, attempt with raw targetOrgId if (String(targetOrgId) !== String(hashedOrgId)) { try { const fallbackRes = await apiClient.get(`/v1/api/governance/organisations/${targetOrgId}/portfolio`); return fallbackRes.data || []; } catch { // ignore } } console.warn('[getOrganisationPortfolios] Failed to retrieve portfolios for organisation:', err); return []; } }; export const getPortfolios = async ( arg?: string | number | Pagination ): Promise => { if (arg === undefined || arg === null || typeof arg === 'object') { return getAllPortfolios(typeof arg === 'object' ? arg : undefined); } return getOrganisationPortfolios(arg); }; export const getPortfolio = async (portfolioId: string | number): Promise => { const hashedPortId = await toolsService.resolvePortfolioId(portfolioId); const res = await apiClient.get(`/v1/api/governance/portfolios/${hashedPortId}`); return Array.isArray(res.data) ? res.data[0] : res.data; }; export const getAllPortfolios = async ( pagination: Pagination = { page: 1, pageSize: 100, index: 0 } ): Promise => { try { const res = await apiClient.post('/v1/api/governance/organisations/portfolios', { index: pagination.index ? toIntegerId(pagination.index) : 0, page: pagination.page || 1, pageSize: pagination.pageSize || 100, }); if (Array.isArray(res.data)) { return res.data; } } catch (err) { console.warn('[getAllPortfolios] POST /v1/api/governance/organisations/portfolios failed, attempting fallback:', err); try { const getRes = await apiClient.get('/v1/api/governance/organisations/portfolios', { params: { page: pagination.page || 1, pageSize: pagination.pageSize || 100 }, }); if (Array.isArray(getRes.data)) { return getRes.data; } } catch { // Fallback to iterating organisations if bulk endpoint is unavailable const orgs = await getOrganisations(1, 10); if (orgs.length > 0) { const results = await Promise.allSettled( orgs.map((org) => getOrganisationPortfolios(org.id)) ); const combined = results .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') .flatMap((r) => r.value); if (combined.length > 0) return combined; } } } return []; }; export const getPortfolioProgrammes = async (portfolioId: string | number): Promise => { const hashedPortId = await toolsService.resolvePortfolioId(portfolioId); const res = await apiClient.get(`/v1/api/governance/portfolios/${hashedPortId}/programmes`); return res.data || []; }; export const createPortfolio = async (payload: CreatePortfolioRequest): Promise => { const userSub = getCurrentUserSub(); let ownerId = payload.ownerId; if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; } const tenantId = getCurrentUserTenantId(); const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null ? toIntegerId(payload.organisationId) : (tenantId !== null ? tenantId : 0); const res = await apiClient.post('/v1/api/governance/organisations/portfolio', { name: payload.name, description: payload.description, organisationId: orgId, ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; }; export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise => { const userSub = getCurrentUserSub(); let ownerId = payload.ownerId; if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; } const res = await apiClient.put('/v1/api/governance/organisations/portfolio', { portfolioId: toIntegerId(payload.portfolioId), name: payload.name, description: payload.description, ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; }; export const getProgrammes = async (portfolioId: string | number): Promise => { const hashedPortId = await toolsService.resolvePortfolioId(portfolioId); const res = await apiClient.get(`/v1/api/governance/programmes/${hashedPortId}`); return res.data || []; }; export const getPortfolioValue = async (portfolioId: string | number): Promise => { const hashedPortId = await toolsService.resolvePortfolioId(portfolioId); try { const res = await apiClient.get(`/v1/api/governance/portfolios/${hashedPortId}/value`); return typeof res.data === 'number' ? res.data : Number(res.data) || 0; } catch (err) { if (String(portfolioId) !== String(hashedPortId)) { try { const fallbackRes = await apiClient.get(`/v1/api/governance/portfolios/${portfolioId}/value`); return typeof fallbackRes.data === 'number' ? fallbackRes.data : Number(fallbackRes.data) || 0; } catch { // ignore } } console.warn('[getPortfolioValue] Failed to get portfolio value:', err); return 0; } }; export const getProgrammeValue = async (programmeId: string | number): Promise => { const hashedProgId = await toolsService.resolveProgrammeId(programmeId); try { const res = await apiClient.get(`/v1/api/governance/programmes/${hashedProgId}/value`); return typeof res.data === 'number' ? res.data : Number(res.data) || 0; } catch (err) { if (String(programmeId) !== String(hashedProgId)) { try { const fallbackRes = await apiClient.get(`/v1/api/governance/programmes/${programmeId}/value`); return typeof fallbackRes.data === 'number' ? fallbackRes.data : Number(fallbackRes.data) || 0; } catch { // ignore } } console.warn('[getProgrammeValue] Failed to get programme value:', err); return 0; } }; export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {}; export const getStoredInstructions = (): Instrument[] => []; export const saveStoredInstruction = (_inst?: Instrument): void => {}; export const getInstruments = async (programmeId: string | number): Promise => { try { const hashedProgId = await toolsService.resolveProgrammeId(programmeId); const res = await apiClient.get(`/v1/api/governance/instruments/${hashedProgId}`); return Array.isArray(res.data) ? res.data : []; } catch (err) { console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err); return []; } }; export const getInstructions = getInstruments; export const getAllInstruments = async (programmesList?: Programme[]): Promise => { let progList = programmesList; if (!progList || progList.length === 0) { try { progList = await getAllProgrammes(1, 100); } catch { progList = []; } } const map = new Map(); if (progList && progList.length > 0) { const results = await Promise.allSettled( progList.map((pr) => getInstruments(pr.id)) ); results.forEach((res) => { if (res.status === 'fulfilled' && Array.isArray(res.value)) { res.value.forEach((inst) => map.set(String(inst.id), inst)); } }); } return Array.from(map.values()); }; export const getAllInstructions = getAllInstruments; export const createInstrument = async (payload: CreateInstrumentRequest): Promise => { const code = payload.code || `INST-${Date.now().toString().slice(-4)}`; const body = { programmeId: toIntegerId(payload.programmeId), name: payload.name, code: code, description: payload.description, }; const res = await apiClient.post('/v1/api/governance/instruments', body); return res.data; }; export const createInstruction = createInstrument; export const updateInstrument = async (payload: UpdateInstrumentRequest): Promise => { const targetId = payload.instructionId ?? payload.instrumentId ?? payload.id; const numId = toIntegerId(targetId); const body = { instructionId: numId, code: payload.code || '', name: payload.name, description: payload.description, status: Number(payload.status), }; const res = await apiClient.put('/v1/api/governance/instruments', body); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Funding Instrument', recordId: targetId!, newStatus: payload.status, title: payload.name, }).catch(console.warn); } return res.data; }; export const updateInstruction = updateInstrument; // ========================================== // 3. Project & Milestone Management APIs // ========================================== export const getAllProgrammes = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get(`/v1/api/projects/programme/${page}/${pageSize}`); return res.data || []; }; export const getProgramme = async (programmeId: string | number): Promise => { const hashedProgId = await toolsService.resolveProgrammeId(programmeId); const res = await apiClient.get(`/v1/api/projects/programme/${hashedProgId}`); return res.data; }; export const getProgrammeProjects = async (programmeId: string | number): Promise => { const hashedProgId = await toolsService.resolveProgrammeId(programmeId); const res = await apiClient.get(`/v1/api/projects/${hashedProgId}/programme`); return res.data || []; }; export const createProgramme = async (payload: CreateProgrammeRequest): Promise => { const userSub = getCurrentUserSub(); let ownerId = payload.ownerId; if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; } const res = await apiClient.post('/v1/api/projects/programme', { portfolioId: toIntegerId(payload.portfolioId), name: payload.name, ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; }; export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise => { const userSub = getCurrentUserSub(); let ownerId = payload.ownerId; if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; } const progStatus = Number(payload.status) || 1; const res = await apiClient.put('/v1/api/projects/programme', { programmeId: toIntegerId(payload.programmeId), name: payload.name, ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', status: progStatus, }); if (isApprovedOrActiveStatus(progStatus)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Programme', recordId: payload.programmeId, newStatus: progStatus, title: payload.name, }).catch(console.warn); } return res.data; }; export const getProjects = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/projects', { params: { page, pageSize }, }); return res.data || []; }; export const getProject = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}`); return Array.isArray(res.data) ? res.data[0] : res.data; }; export const createProject = async (payload: CreateProjectRequest): Promise => { const res = await apiClient.post('/v1/api/projects', { programmeId: toIntegerId(payload.programmeId), name: payload.name, description: payload.description, sector: Number(payload.sector), projectType: Number(payload.projectType), }); return res.data; }; export const updateProject = async (payload: UpdateProjectRequest): Promise => { const startedAt = payload.startedAt ? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString()) : null; const endedAt = payload.endedAt ? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString()) : null; const res = await apiClient.put('/v1/api/projects', { projectId: toIntegerId(payload.projectId), name: payload.name, description: payload.description, startedAt, endedAt, sector: Number(payload.sector), projectType: Number(payload.projectType), }); return res.data; }; export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise => { await apiClient.put('/v1/api/projects/status', { projectId: toIntegerId(payload.projectId), status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Project', recordId: payload.projectId, newStatus: payload.status, }).catch(console.warn); } }; export const getActivities = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/activities`); return res.data || []; }; export const getActivity = async (activityId: string | number): Promise => { const hashedActivityId = await toolsService.encodeId(activityId); const res = await apiClient.get(`/v1/api/projects/activities/${hashedActivityId}`); return res.data; }; export const createActivity = async (payload: CreateActivityRequest): Promise => { const performedAt = payload.performedAt ? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString()) : null; const body = { projectId: toIntegerId(payload.projectId), milestoneId: toNullableIntegerId(payload.milestoneId), category: payload.category || 'Operations', amount: Number(payload.amount) || 0, title: payload.title, description: payload.description || '', performedAt, }; const res = await apiClient.post('/v1/api/projects/activities', body); return res.data; }; export const updateActivity = async (payload: UpdateActivityRequest): Promise => { const performedAt = payload.performedAt ? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString()) : null; const body = { projectId: toIntegerId(payload.projectId), milestoneId: toNullableIntegerId(payload.milestoneId), category: payload.category || 'Operations', amount: Number(payload.amount) || 0, title: payload.title, description: payload.description || '', performedAt, }; try { const res = await apiClient.put('/v1/api/projects/activities', body); return res.data; } catch (err: any) { if (err?.response?.status === 404 || err?.response?.status === 405) { const res = await apiClient.put('/v1/api/projects/activity', body); return res.data; } throw err; } }; export const getMilestones = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/milestones`); return res.data || []; }; export const getAllMilestones = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get(`/v1/api/projects/milestones/${page}/${pageSize}`); return res.data || []; }; export const getMilestone = async (milestoneId: string | number): Promise => { const hashedMilestoneId = await toolsService.encodeId(milestoneId); const res = await apiClient.get(`/v1/api/projects/milestones/${hashedMilestoneId}`); return res.data; }; export const createMilestone = async (payload: CreateMilestoneRequest): Promise => { const dueAtIso = payload.dueAt.includes('T') ? payload.dueAt : new Date(payload.dueAt).toISOString(); const res = await apiClient.post('/v1/api/evidence/milestones', { projectId: toIntegerId(payload.projectId), dueAt: dueAtIso, name: payload.name, priority: Number(payload.priority) || 1, }); return res.data; }; export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise => { const dueAtIso = payload.dueAt.includes('T') ? payload.dueAt : new Date(payload.dueAt).toISOString(); await apiClient.put('/v1/api/projects/milestone', { milestoneId: toIntegerId(payload.milestoneId), dueAt: dueAtIso, name: payload.name, priority: Number(payload.priority) || 1, status: Number(payload.status) || 1, }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Milestone', recordId: payload.milestoneId, newStatus: payload.status, title: payload.name, }).catch(console.warn); } }; export const getProjectIndicators = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); const res = await apiClient.get(`/v1/api/projects/indicators/project/${hashedProjectId}`); return res.data || []; }; export const getProjectRisks = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/risks`); const list = res.data || []; return list.map(normalizeRisk); }; // ========================================== // 4. Grant & Financial Management APIs // ========================================== export const getAwards = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/grants/awards', { params: { page, pageSize }, }); return res.data || []; }; export const getAward = async (awardId: string | number): Promise => { const hashedAwardId = await toolsService.resolveAwardId(awardId); const res = await apiClient.get(`/v1/api/grants/awards/${hashedAwardId}`); return res.data; }; export const createAward = async (payload: CreateAwardRequest): Promise => { const tenantId = getCurrentUserTenantId(); const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null ? toIntegerId(payload.organisationId) : (tenantId !== null ? tenantId : 0); const res = await apiClient.post('/v1/api/grants/awards', { organisationId: orgId, programmeId: toIntegerId(payload.programmeId), amount: Number(payload.amount), }); return res.data; }; export const updateAward = async (payload: UpdateAwardRequest): Promise => { const numericAwardId = toIntegerId(payload.awardId); const numericOrgId = toIntegerId(payload.organisationId); const numericProgId = toIntegerId(payload.programmeId); await apiClient.put('/v1/api/grants/awards', { awardId: numericAwardId, organisationId: numericOrgId, programmeId: numericProgId, amount: Number(payload.amount), ...(payload.status !== undefined ? { status: Number(payload.status) } : {}), }); if (payload.status === undefined || isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Award', recordId: payload.awardId, newStatus: payload.status !== undefined ? payload.status : ApprovalStatus.Approved, }).catch(console.warn); } }; export const fetchHashId = async (id64: number | string): Promise => { try { const res = await apiClient.get(`/v1/api/tools/hashid/${id64}`); if (res.data && typeof res.data === 'string') { return res.data; } } catch { // Fall back to client-side deterministic Hashids } return toolsService.encodeIdSync(id64); }; export const getContracts = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/grants/contracts', { params: { page, pageSize }, }); return res.data || []; }; export const getContract = async (contractId: string | number): Promise => { const hashedContractId = await toolsService.resolveContractId(contractId); const res = await apiClient.get(`/v1/api/grants/contracts/${hashedContractId}`); return res.data; }; export const createContract = async (payload: CreateContractRequest): Promise => { // Ensure awardId is numeric int64 as required by OpenAPI schema (pattern: ^-?(?:0|[1-9]\d*)$) let numericAwardId: number = Number(payload.awardId); if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) { const decoded = toolsService.decodeIdSync(String(payload.awardId)); if (decoded && /^-?\d+$/.test(String(decoded))) { numericAwardId = Number(decoded); } } const effectiveAtIso = payload.effectiveAt.includes('T') ? payload.effectiveAt : new Date(payload.effectiveAt).toISOString(); const expiresAtIso = payload.expiresAt.includes('T') ? payload.expiresAt : new Date(payload.expiresAt).toISOString(); const res = await apiClient.post('/v1/api/grants/contracts', { awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId, totalValue: Number(payload.totalValue), effectiveAt: effectiveAtIso, expiresAt: expiresAtIso, }); // Extract ID from headers (e.g. Location or Content-Location) if present const locationHeader = res.headers?.['location'] || res.headers?.['Location']; let headerId: string | undefined; if (locationHeader && typeof locationHeader === 'string') { const parts = locationHeader.split('/'); headerId = parts[parts.length - 1]; } const hashedAwardId = toolsService.encodeIdSync(numericAwardId || payload.awardId); // Case 1: Backend returns string contractId/hash directly if (typeof res.data === 'string' && res.data.trim().length > 0) { const rawStr = res.data.trim(); return { id: rawStr, contractId: rawStr, awardId: hashedAwardId, totalValue: Number(payload.totalValue), effectiveAt: effectiveAtIso, expiresAt: expiresAtIso, status: ContractStatus.Draft, } as unknown as Contract; } // Case 2: Backend returns object if (res.data && typeof res.data === 'object') { const candidateId = res.data.id ?? res.data.contractId ?? res.data.Id ?? res.data.ContractId ?? headerId; if (candidateId) { return { ...res.data, id: candidateId, contractId: candidateId, awardId: res.data.awardId || hashedAwardId, }; } } // Case 3: ID extracted from Location header if (headerId) { return { id: headerId, contractId: headerId, awardId: hashedAwardId, totalValue: Number(payload.totalValue), effectiveAt: effectiveAtIso, expiresAt: expiresAtIso, status: ContractStatus.Draft, } as unknown as Contract; } // Case 4: If backend returns empty 200 OK without body, locate newly created contract by awardId try { const existing = await getContracts(1, 10); const matched = existing.find( (c) => String(c.awardId) === String(hashedAwardId) || String(c.awardId) === String(numericAwardId) || String(c.awardId) === String(payload.awardId) ); if (matched) return matched; } catch {} return (res.data || { id: hashedAwardId, contractId: hashedAwardId, awardId: hashedAwardId, totalValue: Number(payload.totalValue), effectiveAt: effectiveAtIso, expiresAt: expiresAtIso, status: ContractStatus.Draft, }) as unknown as Contract; }; export const updateContract = async (payload: UpdateContractRequest): Promise => { await apiClient.put('/v1/api/grants/contracts', { contractId: toIntegerId(payload.contractId), status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Contract', recordId: payload.contractId, newStatus: payload.status, }).catch(console.warn); } }; export const uploadContractFile = async ( contractId: string | number, file: File | FormData ): Promise => { const hashedContractId = await toolsService.resolveContractId(contractId); const formData = file instanceof FormData ? file : new FormData(); if (file instanceof File) { formData.append('file', file); } await apiClient.post(`/v1/api/grants/contracts/${hashedContractId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); }; export const getContractSecureLink = async (contractId: string | number): Promise => { const hashedContractId = await toolsService.resolveContractId(contractId); const res = await apiClient.get( `/v1/api/grants/contracts/${hashedContractId}/securelink` ); if (typeof res.data === 'string') return res.data; return (res.data as any)?.url || (res.data as any)?.link || String(res.data); }; export const executeContract = async (payload: ExecuteContractRequest): Promise => { await apiClient.post('/v1/api/grants/contracts/execute', { contractId: toIntegerId(payload.contractId), externalSignatureId: payload.externalSignatureId, signedDocumentUrl: payload.signedDocumentUrl, }); }; export const getBudgets = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/grants/budgets', { params: { page, pageSize }, }); return res.data || []; }; export const getBudget = async (budgetId: string | number): Promise => { const hashedBudgetId = await toolsService.encodeId(budgetId); const res = await apiClient.get(`/v1/api/grants/budgets/${hashedBudgetId}`); return res.data; }; export const createBudget = async (payload: CreateBudgetRequest): Promise => { const res = await apiClient.post('/v1/api/grants/budgets', { projectId: toIntegerId(payload.projectId), name: payload.name, amount: Number(payload.amount), }); return res.data; }; export const updateBudget = async (payload: UpdateBudgetRequest): Promise => { await apiClient.put('/v1/api/grants/budgets', { budgetId: toIntegerId(payload.budgetId), status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Budget', recordId: payload.budgetId, newStatus: payload.status, }).catch(console.warn); } }; // ========================================== // 5. Execution (Disbursements & Risks) APIs // ========================================== export const getDisbursements = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/execution/disbursements', { params: { page, pageSize }, }); return res.data || []; }; export const getDisbursement = async (disbursementId: string | number): Promise => { const hashedDisbId = await toolsService.encodeId(disbursementId); const res = await apiClient.get(`/v1/api/execution/disbursements/${hashedDisbId}`); return res.data; }; export const createDisbursement = async (payload: CreateDisbursementRequest): Promise => { const res = await apiClient.post('/v1/api/execution/disbursements', { projectId: toIntegerId(payload.projectId), milestoneId: toNullableIntegerId(payload.milestoneId), amount: Number(payload.amount), }); return res.data; }; export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise => { await apiClient.put('/v1/api/execution/disbursements', { disbursementId: toIntegerId(payload.disbursementId), milestoneId: toNullableIntegerId(payload.milestoneId), amount: Number(payload.amount), }); }; export const approveDisbursement = async (disbursementId: string | number): Promise => { const hashedDisbId = await toolsService.encodeId(disbursementId); await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {}); notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Disbursement', recordId: disbursementId, newStatus: ApprovalStatus.Approved, }).catch(console.warn); }; /** * Normalizes a Risk record from backend responses. * Robustly parses Likelihood (1-5) and Impact (1-5), supporting strings, numbers, and case variations. */ export const normalizeRisk = (raw: any): Risk => { if (!raw) return raw; const rawLik = raw.likelihood ?? raw.Likelihood ?? raw.riskLikelihood ?? raw.RiskLikelihood; const rawImp = raw.impact ?? raw.Impact ?? raw.riskImpact ?? raw.RiskImpact; const parseScore = (val: any, defaultVal: number): number => { if (typeof val === 'number' && !isNaN(val)) { return Math.min(5, Math.max(1, Math.round(val))); } if (typeof val === 'string') { const parsed = parseInt(val, 10); if (!isNaN(parsed) && parsed >= 1 && parsed <= 5) { return parsed; } const lower = val.toLowerCase().trim(); if (lower.includes('rare') || lower.includes('insignificant') || lower.includes('trivial') || lower === '1') return 1; if (lower.includes('unlikely') || lower.includes('minor') || lower === '2') return 2; if (lower.includes('possible') || lower.includes('moderate') || lower.includes('medium') || lower === '3') return 3; if (lower.includes('likely') || lower.includes('major') || lower.includes('high') || lower === '4') return 4; if (lower.includes('almost') || lower.includes('certain') || lower.includes('severe') || lower.includes('critical') || lower.includes('catastrophic') || lower === '5') return 5; } return defaultVal; }; const likelihood = parseScore(rawLik, 3); const impact = parseScore(rawImp, 2); return { ...raw, id: raw.id ?? raw.Id ?? raw.riskId ?? raw.RiskId, projectId: raw.projectId ?? raw.ProjectId, name: raw.name ?? raw.Name ?? null, description: raw.description ?? raw.Description ?? null, likelihood, impact, createdBy: raw.createdBy ?? raw.CreatedBy ?? '', createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(), owner: raw.owner ?? raw.Owner ?? raw.riskOwner ?? raw.RiskOwner ?? raw.createdBy ?? raw.CreatedBy ?? '', mitigationAction: raw.mitigationAction ?? raw.MitigationAction ?? raw.mitigation ?? raw.Mitigation ?? '', status: raw.status ?? raw.Status, }; }; export const getAllRisks = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get('/v1/api/execution/risks', { params: { page, pageSize }, }); const list = res.data || []; return list.map(normalizeRisk); }; export const getRisk = async (riskId: string | number): Promise => { const hashedRiskId = await toolsService.encodeId(riskId); const res = await apiClient.get(`/v1/api/execution/risks/${hashedRiskId}`); return normalizeRisk(res.data); }; export const createRisk = async (payload: CreateRiskRequest): Promise => { const res = await apiClient.post('/v1/api/execution/risks', { projectId: toIntegerId(payload.projectId), likelihood: Number(payload.likelihood), impact: Number(payload.impact), name: payload.name, description: payload.description || '', }); return normalizeRisk(res.data); }; export const updateRisk = async (payload: UpdateRiskRequest): Promise => { await apiClient.put('/v1/api/execution/risks', { riskId: toIntegerId(payload.riskId), likelihood: Number(payload.likelihood), impact: Number(payload.impact), name: payload.name, description: payload.description || '', }); }; // ========================================== // 6. Evidence & M&E APIs // ========================================== export const getAllIndicators = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get(`/v1/api/evidence/indicators/${page}/${pageSize}`); return res.data || []; }; export const getIndicator = async (indicatorId: string | number): Promise => { const hashedIndId = await toolsService.encodeId(indicatorId); const res = await apiClient.get(`/v1/api/evidence/indicators/${hashedIndId}`); return res.data; }; export const createIndicator = async (payload: CreateIndicatorRequest): Promise => { const res = await apiClient.post('/v1/api/evidence/indicators', { projectId: toIntegerId(payload.projectId), activityId: toNullableIntegerId(payload.activityId), name: payload.name, unitOfMeasure: Number(payload.unitOfMeasure) || 1, baselineAmount: Number(payload.baselineAmount) || 0, targetAmount: Number(payload.targetAmount) || 0, targetCost: Number(payload.targetCost) || 0, }); return res.data; }; export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise => { const hashedIndId = await toolsService.encodeId(indicatorId); await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {}); }; export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise => { try { const res = await apiClient.get(`/v1/api/evidence/files/${page}/${pageSize}`); return res.data || []; } catch (err: any) { if (err?.response?.status === 404) { return []; } throw err; } }; export const getAllEvidence = async (page = 1, pageSize = 100): Promise => { return getEvidenceFiles(page, pageSize); }; export const getEvidence = async (evidenceId: string | number): Promise => { const hashedEvId = await toolsService.encodeId(evidenceId); const res = await apiClient.get(`/v1/api/evidence/${hashedEvId}`); return res.data; }; export const getEvidenceSecureLink = async (evidenceId: string | number): Promise => { const hashedEvId = await toolsService.encodeId(evidenceId); const res = await apiClient.get(`/v1/api/evidence/${hashedEvId}/securelink`); return res.data; }; export const getMilestoneEvidence = async (milestoneId: string | number): Promise => { try { const hashedMId = await toolsService.encodeId(milestoneId); const res = await apiClient.get(`/v1/api/evidence/milestone/${hashedMId}`); return res.data || []; } catch (err: any) { if (err?.response?.status === 404) { return []; } throw err; } }; export const getProjectEvidence = async (projectId: string | number): Promise => { try { const hashedProjectId = await toolsService.resolveProjectId(projectId); const res = await apiClient.get(`/v1/api/evidence/projects/${hashedProjectId}`); return res.data || []; } catch (err: any) { // If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') { try { const rawRes = await apiClient.get(`/v1/api/evidence/projects/${projectId}`); return rawRes.data || []; } catch (fallbackErr: any) { if (fallbackErr?.response?.status === 404) { return []; } } } if (err?.response?.status === 404) { return []; } throw err; } }; export const uploadEvidenceFile = async (formData: FormData): Promise => { const userSub = getCurrentUserSub(); if (userSub && !formData.get('userId')) { formData.append('userId', userSub); } const res = await apiClient.put('/v1/api/evidence/file', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return res.data; }; export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise => { await apiClient.put('/v1/api/evidence', { evidenceId: toIntegerId(payload.evidenceId), version: Number(payload.version) || 1, evidenceType: Number(payload.evidenceType), status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Evidence', recordId: payload.evidenceId, newStatus: payload.status, }).catch(console.warn); } }; // ========================================== // 7. Users & Background Integration APIs // ========================================== export const getUsers = async ( page = 1, pageSize = 100, filter: UserFilterRequest = {} ): Promise => { const tenantId = getCurrentUserTenantId(); // Unless otherwise specified by the user, ensure tenant_id is passed into endpoints where organisationId is required for system activities const orgId = filter.organisationId !== undefined ? (filter.organisationId !== null && filter.organisationId !== '' ? toIntegerId(filter.organisationId) : null) : tenantId; const requestBody: Record = { ...filter, }; if (orgId !== null && orgId !== undefined) { requestBody.organisationId = orgId; } // HashID query parameter for query string protection const queryParams: Record = {}; if (orgId !== null && orgId !== undefined) { const hashedOrgId = await toolsService.encodeId(orgId); if (hashedOrgId) { queryParams.organisationId = hashedOrgId; } } const res = await apiClient.post( `/v1/api/users/${page}/${pageSize}`, requestBody, { params: Object.keys(queryParams).length > 0 ? queryParams : undefined } ); const list = res.data || []; registerUserEmails(list); return list; }; export const getUser = async (userId: string): Promise => { const res = await apiClient.get(`/v1/api/users/${userId}`); if (res.data?.id && res.data?.email) { registerUserEmail(res.data.id, res.data.email); } return res.data; }; export const getCurrentUser = async (): Promise => { const userSub = getCurrentUserSub(); if (userSub) { try { const u = await getUser(userSub); if (u?.id && u?.email) { registerUserEmail(u.id, u.email); } return u; } catch { // Fallback } } return { id: userSub || 'user-1', email: null, status: ActivityStatus.Active, lastLoginAt: new Date().toISOString(), }; }; export const updateUserStatus = async (userId: string, status: ActivityStatus | number): Promise => { await apiClient.get(`/v1/api/users/${userId}/status/${status}`); }; export const createUser = async ( tenantId: number | string, idNumber: string, payload: CreateUserRequest ): Promise => { const encodedTenant = encodeEntityId(tenantId) || String(tenantId); const encodedIdNumber = encodeIdNumberHex(idNumber); // Normalize and sanitize userName: Duende / Skoruba Identity Server rejects usernames with spaces const rawEmail = payload.email?.trim().toLowerCase() || ''; const rawUserName = payload.userName?.trim(); let sanitizedUserName = rawUserName ? rawUserName.toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '') : rawEmail.split('@')[0]; if (!sanitizedUserName) { sanitizedUserName = rawEmail; } await apiClient.post(`/v1/api/users/${encodeURIComponent(encodedTenant)}/${encodeURIComponent(encodedIdNumber)}`, { id: payload.id || null, userName: sanitizedUserName, email: rawEmail || null, emailConfirmed: payload.emailConfirmed ?? true, phoneNumber: payload.phoneNumber || null, phoneNumberConfirmed: payload.phoneNumberConfirmed ?? false, LockoutEnabled: payload.LockoutEnabled ?? false, twoFactorEnabled: payload.twoFactorEnabled ?? false, accessFailedCount: payload.accessFailedCount !== undefined ? Number(payload.accessFailedCount) : 0, lockoutEnd: payload.lockoutEnd || null, }); }; import { fetchAndCacheSystemRoles, fetchAndCacheRoleClaims, getSystemClaims, getCachedSystemRoles, getCachedRoleClaims, preloadAndCacheAllRoleClaims, } from './roleClaimsCache'; export { getSystemClaims, getCachedSystemRoles, getCachedRoleClaims, preloadAndCacheAllRoleClaims, }; export const getSystemRoles = async (): Promise => { return await fetchAndCacheSystemRoles(); }; export const getUserRoles = async (userId: string): Promise => { const res = await apiClient.get(`/v1/api/users/${userId}/roles`); return res.data || []; }; export const assignUserRole = async (userId: string, roleId: string): Promise => { await apiClient.patch(`/v1/api/users/${userId}/roles/${roleId}`); }; export const removeUserRole = async (userId: string, roleId: string): Promise => { await apiClient.delete(`/v1/api/users/${userId}/roles/${roleId}`); }; export const getRoleClaims = async (roleId: string, roleName?: string): Promise => { return await fetchAndCacheRoleClaims(roleId, roleName); }; export const getUserClaims = async (userId: string): Promise => { const res = await apiClient.get(`/v1/api/users/${userId}/claims`); return res.data || []; }; export const addUserClaim = async (payload: AddUserClaimRequest): Promise => { await apiClient.patch('/v1/api/users/user/claims', { userId: payload.userId, claimId: Number(payload.claimId), claimType: payload.claimType, claimValue: payload.claimValue, }); }; export const deleteUserClaim = async (targetUserId: string, claimId: string | number): Promise => { await apiClient.delete(`/v1/api/users/${targetUserId}/claims/${claimId}`); }; export const forceLogoutUser = async (userId: string): Promise => { await apiClient.put(`/v1/api/users/${userId}/session/logout`); }; export const setUserLockout = async (userId: string, disable: boolean): Promise => { await apiClient.put(`/v1/api/users/${userId}/session/lockout/${disable}`); }; import { syncUserAccount, ensureUserSynced, isUserSyncedInBrowser, saveUserSyncInBrowser, clearUserSyncInBrowser, getUserSyncRecord, isActivationPending, setActivationPending, getActivationPendingReason, } from './userSyncService'; export { syncUserAccount, ensureUserSynced, isUserSyncedInBrowser, saveUserSyncInBrowser, clearUserSyncInBrowser, getUserSyncRecord, isActivationPending, setActivationPending, getActivationPendingReason, }; /** * Synchronizes the authenticated user with the backend to enable the user in the system. * By default, sync is saved in the browser to prevent hotspotting the sync endpoint. */ export const syncUser = async ( userData?: { id?: string; sub?: string; email?: string | null; name?: string | null; role?: string | null; tenant?: string | null; }, force = false ): Promise => { return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force); }; export const syncIntegration = async (force = true): Promise => { try { await apiClient.post('/v1/api/integration/sync', {}); } catch { await ensureUserSynced(undefined, undefined, undefined, force); } }; // ========================================== // 8. Communications & Invites APIs // ========================================== export const sendInvite = async (payload: InviteRequest): Promise => { const tenantId = getCurrentUserTenantId(); const hostOrgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null ? toSafeInt64String(payload.organisationId) : (tenantId !== null ? String(tenantId) : '0'); const body = { organisationId: hostOrgId, invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId), awardId: toSafeInt64String(payload.awardId), contractId: toSafeInt64String(payload.contractId), recipient: payload.recipient, message: payload.message, platform: payload.platform !== undefined ? Number(payload.platform) : 1, }; const res = await apiClient.post('/v1/api/communications/invites', body); return res.data; }; export const getInvites = async (page = 1, pageSize = 100): Promise => { const res = await apiClient.get(`/v1/api/communications/invites/${page}/${pageSize}`); return res.data || []; }; export const getApplicantData = async (applicantUserId: string): Promise => { const res = await apiClient.get(`/v1/api/communications/applicants/${applicantUserId}/all`); return res.data; }; export const getApplicantInvite = async (applicantUserId: string): Promise => { try { const res = await apiClient.get(`/v1/api/communications/invites/applicants/${applicantUserId}`); return res.data; } catch (err: any) { if (err?.response?.status === 404) { const res = await apiClient.get(`/v1/api/communications/invites/applicant/${applicantUserId}`); return res.data; } throw err; } }; export const getApplicantInvites = async (applicantUserId: string): Promise => { const res = await apiClient.get(`/v1/api/communications/invites/${applicantUserId}`); return res.data || []; }; export const getNotifications = async (organisationId?: string | number): Promise => { const targetOrgId = organisationId !== undefined && organisationId !== '' && organisationId !== null && organisationId !== 'all' ? organisationId : getCurrentUserTenantId(); if (!targetOrgId || targetOrgId === 'all') { return []; } const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId); try { const res = await apiClient.get( `/v1/api/communications/notifications/${encodeURIComponent(hashedOrgId)}` ); if (res.data && Array.isArray(res.data)) { return res.data; } } catch (err: any) { console.warn('[getNotifications] API error fetching notifications:', err); } return []; }; export const getUserNotifications = async (userId?: string): Promise => { try { const url = userId ? `/v1/api/communications/notifications/user/${encodeURIComponent(userId)}` : `/v1/api/communications/notifications/inbox`; const res = await apiClient.get(url); if (res.data && Array.isArray(res.data)) { return res.data; } } catch (err: any) { console.warn('[getUserNotifications] API error:', err); } return []; }; export const createNotification = async (payload: CreateNotificationRequest): Promise => { const tenantId = getCurrentUserTenantId(); const orgId = payload.organisationId !== undefined && payload.organisationId !== '' && payload.organisationId !== null ? toIntegerId(payload.organisationId) : (tenantId !== null ? tenantId : 1); await apiClient.post('/v1/api/communications/notifications', { organisationId: orgId, recipient: payload.recipient, platform: payload.platform, subject: payload.subject || null, destination: payload.destination || 'In-App User Inbox', message: payload.message, status: payload.status !== undefined ? payload.status : undefined, }); }; export const updateNotification = async (payload: UpdateNotificationRequest): Promise => { await apiClient.put('/v1/api/communications/notifications', { notificationId: toIntegerId(payload.notificationId), destination: payload.destination || undefined, status: payload.status, sentAt: payload.sentAt || new Date().toISOString(), error: payload.error || null, }); }; export const markNotificationAsRead = async (notificationId: number | string): Promise => { await updateNotification({ notificationId: toIntegerId(notificationId), status: NotificationStatus.Read, }); }; // ========================================== // 9. Governance: Tenants APIs // ========================================== export const getTenants = async (): Promise => { const res = await apiClient.get('/v1/api/governance/tenants'); return res.data || []; }; export const getTenant = async (tenantId: string | number): Promise => { const hashedTenantId = await toolsService.encodeId(tenantId); const res = await apiClient.get(`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}`); return res.data; }; export const createTenant = async (payload: CreateTenantRequest): Promise => { await apiClient.post('/v1/api/governance/tenants', { name: payload.name, }); }; export const updateTenant = async (payload: UpdateTenantRequest): Promise => { await apiClient.put('/v1/api/governance/tenants', { tenantId: toIntegerId(payload.tenantId), name: payload.name, status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Tenant', recordId: payload.tenantId, newStatus: payload.status, title: payload.name, }).catch(console.warn); } }; export const getTenantOrganisations = async (tenantId: string | number): Promise => { const hashedTenantId = await toolsService.encodeId(tenantId); try { const res = await apiClient.get( `/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/organisations` ); return res.data || []; } catch (err: any) { if (String(tenantId) !== String(hashedTenantId)) { try { const fallbackRes = await apiClient.get( `/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/organisations` ); return fallbackRes.data || []; } catch {} } throw err; } }; export const getTenantUsers = async (tenantId: string | number): Promise => { const hashedTenantId = await toolsService.encodeId(tenantId); try { const res = await apiClient.get( `/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/users` ); return res.data || []; } catch (err: any) { if (String(tenantId) !== String(hashedTenantId)) { try { const fallbackRes = await apiClient.get( `/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/users` ); return fallbackRes.data || []; } catch {} } throw err; } }; // ========================================== // 10. Projects: Beneficiaries & Outcomes APIs // ========================================== export const getProjectBeneficiaries = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); try { const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/beneficiaries`); return res.data || []; } catch (err: any) { if (String(projectId) !== String(hashedProjectId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/${projectId}/beneficiaries`); return fallbackRes.data || []; } catch { // ignore } } throw err; } }; export const getBeneficiary = async (beneficiaryId: string | number): Promise => { const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId); try { const res = await apiClient.get(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}`); return res.data; } catch (err: any) { if (String(beneficiaryId) !== String(hashedBeneficiaryId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/beneficiaries/${beneficiaryId}`); return fallbackRes.data; } catch {} } throw err; } }; export const createBeneficiary = async (payload: CreateBeneficiaryRequest): Promise => { await apiClient.post('/v1/api/projects/beneficiaries', { projectId: toIntegerId(payload.projectId), name: payload.name, category: payload.category, location: payload.location, targetCount: Number(payload.targetCount), reachedCount: Number(payload.reachedCount), }); }; export const updateBeneficiary = async (payload: UpdateBeneficiaryRequest): Promise => { await apiClient.put('/v1/api/projects/beneficiaries', { beneficiaryId: toIntegerId(payload.beneficiaryId), name: payload.name, category: payload.category, location: payload.location, targetCount: Number(payload.targetCount), reachedCount: Number(payload.reachedCount), }); }; export const getOutcome = async (outcomeId: string | number): Promise => { const hashedOutcomeId = await toolsService.encodeId(outcomeId); try { const res = await apiClient.get(`/v1/api/projects/outcome/${hashedOutcomeId}`); return res.data; } catch { const res = await apiClient.get(`/v1/api/projects/outcomes/single/${hashedOutcomeId}`); return res.data; } }; export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise => { const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId); try { const res = await apiClient.get(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}/outcomes`); return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []); } catch (err: any) { if (err?.response?.status === 404) { try { const res = await apiClient.get(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`); return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []); } catch { return []; } } return []; } }; export const getProjectOutcomes = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); try { const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/outcomes`); return Array.isArray(res.data) ? res.data : []; } catch { return []; } }; export const createOutcome = async (payload: CreateOutcomeRequest): Promise => { const body = { beneficiaryId: toIntegerId(payload.beneficiaryId), name: payload.name, description: payload.description, status: payload.status !== undefined ? Number(payload.status) : 1, }; try { await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body); } catch (err: any) { if (err?.response?.status === 404) { await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body); } else { throw err; } } }; export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise => { await apiClient.put('/v1/api/projects/outcomes', { outcomeId: toIntegerId(payload.outcomeId), name: payload.name, description: payload.description, status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Outcome', recordId: payload.outcomeId, newStatus: payload.status, title: payload.name, }).catch(console.warn); } }; // ========================================== // 11. Projects: Change Requests APIs // ========================================== export const getProjectChangeRequests = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); try { const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/changerequests`); return res.data || []; } catch (err: any) { if (String(projectId) !== String(hashedProjectId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/${projectId}/changerequests`); return fallbackRes.data || []; } catch { // ignore } } throw err; } }; export const getChangeRequest = async (changeRequestId: string | number): Promise => { const hashedChangeReqId = await toolsService.encodeId(changeRequestId); try { const res = await apiClient.get(`/v1/api/projects/changerequests/${hashedChangeReqId}`); return res.data; } catch (err: any) { if (String(changeRequestId) !== String(hashedChangeReqId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/changerequests/${changeRequestId}`); return fallbackRes.data; } catch {} } throw err; } }; export const createChangeRequest = async (payload: CreateChangeRequest): Promise => { const revisionDateIso = payload.revisionDate.includes('T') ? payload.revisionDate : new Date(payload.revisionDate).toISOString(); await apiClient.post('/v1/api/projects/changerequests', { projectId: toIntegerId(payload.projectId), title: payload.title, justification: payload.justification, revisedBudget: Number(payload.revisedBudget), revisionDate: revisionDateIso, }); }; export const updateChangeRequest = async (payload: UpdateChangeRequest): Promise => { await apiClient.put('/v1/api/projects/changerequests', { changeRequestId: toIntegerId(payload.changeRequestId), title: payload.title, justification: payload.justification, revisedBudget: Number(payload.revisedBudget), status: Number(payload.status), }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Change Request', recordId: payload.changeRequestId, newStatus: payload.status, title: payload.title, }).catch(console.warn); } }; // ========================================== // 12. Projects: Compliance Items APIs // ========================================== export const getProjectComplianceItems = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); try { // Primary updated route: /v1/api/projects/{projectId}/complianceitems const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/complianceitems`); return res.data || []; } catch (err: any) { if (err?.response?.status === 404 || err?.response?.status === 400) { try { // Fallback without /v1 prefix: /api/projects/{projectId}/complianceitems const fallbackRes = await apiClient.get(`/api/projects/${hashedProjectId}/complianceitems`); return fallbackRes.data || []; } catch { try { // Unencoded project ID fallbacks const rawIdRes = await apiClient.get(`/v1/api/projects/${projectId}/complianceitems`); return rawIdRes.data || []; } catch { try { const rawApiRes = await apiClient.get(`/api/projects/${projectId}/complianceitems`); return rawApiRes.data || []; } catch { throw err; } } } } throw err; } }; export const getComplianceItem = async (complianceItemId: string | number): Promise => { const hashedItemId = await toolsService.encodeId(complianceItemId); const res = await apiClient.get(`/v1/api/projects/complianceitems/${hashedItemId}`); return res.data; }; export const createComplianceItem = async (payload: CreateComplianceItemRequest): Promise => { await apiClient.post('/v1/api/projects/complianceitems', { projectId: toIntegerId(payload.projectId), title: payload.title, requirements: payload.requirements, status: payload.status !== undefined ? Number(payload.status) : 1, beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null, }); }; export const updateComplianceItem = async (payload: UpdateComplianceItemRequest): Promise => { await apiClient.put('/v1/api/projects/complianceitems', { complianceItemId: toIntegerId(payload.complianceItemId), title: payload.title, requirements: payload.requirements, status: Number(payload.status), beneficiaryId: payload.beneficiaryId ? toIntegerId(payload.beneficiaryId) : null, }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Compliance Item', recordId: payload.complianceItemId, newStatus: payload.status, title: payload.title, }).catch(console.warn); } }; // ========================================== // 13. Projects: Issues APIs // ========================================== export const getProjectIssues = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); try { const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/issues`); return res.data || []; } catch (err: any) { if (String(projectId) !== String(hashedProjectId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/${projectId}/issues`); return fallbackRes.data || []; } catch { // ignore } } throw err; } }; export const getIssue = async (issueId: string | number): Promise => { const hashedIssueId = await toolsService.encodeId(issueId); const res = await apiClient.get(`/v1/api/projects/issues/${hashedIssueId}`); return res.data; }; export const createIssue = async (payload: CreateIssueRequest): Promise => { await apiClient.post('/v1/api/projects/issues', { projectId: toIntegerId(payload.projectId), title: payload.title, description: payload.description, priority: Number(payload.priority), assignedTo: payload.assignedTo, }); }; export const updateIssue = async (payload: UpdateIssueRequest): Promise => { let resolvedAtIso: string | null = null; if (payload.resolvedAt) { try { resolvedAtIso = payload.resolvedAt.includes('T') && payload.resolvedAt.endsWith('Z') ? payload.resolvedAt : new Date(payload.resolvedAt).toISOString(); } catch { resolvedAtIso = new Date().toISOString(); } } else { resolvedAtIso = new Date().toISOString(); } const finalResolution = payload.resolution && payload.resolution.trim() !== '' ? payload.resolution : (Number(payload.status) === ActivityStatus.Inactive ? 'Resolved' : 'Under active investigation'); await apiClient.put('/v1/api/projects/issues', { issueId: toIntegerId(payload.issueId), title: payload.title, description: payload.description, resolution: finalResolution, resolvedAt: resolvedAtIso, priority: Number(payload.priority), status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active, assignedTo: payload.assignedTo, }); const issueStatus = payload.status !== undefined ? payload.status : ActivityStatus.Active; if (isApprovedOrActiveStatus(issueStatus)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Issue', recordId: payload.issueId, newStatus: issueStatus, title: payload.title, }).catch(console.warn); } }; // ========================================== // 14. Projects: Site Visits APIs // ========================================== export const getProjectSiteVisits = async (projectId: string | number): Promise => { const hashedProjectId = await toolsService.resolveProjectId(projectId); try { const res = await apiClient.get(`/v1/api/projects/${hashedProjectId}/sitevisits`); return res.data || []; } catch (err: any) { if (String(projectId) !== String(hashedProjectId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/${projectId}/sitevisits`); return fallbackRes.data || []; } catch { // ignore } } throw err; } }; export const getSiteVisit = async (siteVisitId: string | number): Promise => { const hashedVisitId = await toolsService.encodeId(siteVisitId); try { const res = await apiClient.get(`/v1/api/projects/sitevisits/${hashedVisitId}`); return res.data; } catch (err: any) { if (String(siteVisitId) !== String(hashedVisitId)) { try { const fallbackRes = await apiClient.get(`/v1/api/projects/sitevisits/${siteVisitId}`); return fallbackRes.data; } catch {} } throw err; } }; export const createSiteVisit = async (payload: CreateSiteVisitRequest): Promise => { const visitedAtIso = payload.visitedAt.includes('T') ? payload.visitedAt : new Date(payload.visitedAt).toISOString(); await apiClient.post('/v1/api/projects/sitevisits', { projectId: toIntegerId(payload.projectId), inspectorName: payload.inspectorName, findings: payload.findings, visitedAt: visitedAtIso, }); }; export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise => { const visitedAtIso = payload.visitedAt.includes('T') ? payload.visitedAt : new Date(payload.visitedAt).toISOString(); await apiClient.put('/v1/api/projects/sitevisits', { siteVisitId: toIntegerId(payload.siteVisitId), inspectorName: payload.inspectorName, findings: payload.findings, status: Number(payload.status), visitedAt: visitedAtIso, }); if (isApprovedOrActiveStatus(payload.status)) { notifyTenantManagersAndExecutivesOnRecordStatusChange({ recordType: 'Site Visit', recordId: payload.siteVisitId, newStatus: payload.status, title: payload.inspectorName, }).catch(console.warn); } }; export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {}; export const getStoredRules = (): Rule[] => []; export const saveStoredRule = (_rule?: Rule): void => {}; // ========================================== // 15. Execution: Programme Rules APIs // ========================================== export const getRule = async (ruleId: string | number): Promise => { const hashedRuleId = await toolsService.encodeId(ruleId); const res = await apiClient.get(`/v1/api/execution/rules/${hashedRuleId}`); if (res.data) { return res.data; } throw new Error('Rule not found'); }; const toValidUuid = (userId?: string | null): string => { if (!userId) return '00000000-0000-0000-0000-000000000000'; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; if (uuidPattern.test(userId)) return userId; const userSub = getCurrentUserSub(); if (userSub && uuidPattern.test(userSub)) return userSub; return '00000000-0000-0000-0000-000000000000'; }; export const getRules = async ( pagination: Pagination = { page: 1, pageSize: 100 }, programmeId?: string | number ): Promise => { const res = await apiClient.post('/v1/api/execution/programmes/rules', { index: pagination.index ? toIntegerId(pagination.index) : 0, page: pagination.page || 1, pageSize: pagination.pageSize || 100, }); let result = Array.isArray(res.data) ? res.data : []; if (programmeId != null && programmeId !== '' && programmeId !== 'all') { result = result.filter((r) => String(r.programmeId) === String(programmeId)); } return result; }; export const createRule = async (payload: CreateRuleRequest): Promise => { const validUserId = toValidUuid(payload.userId); const body = { programmeId: toIntegerId(payload.programmeId), userId: validUserId, name: payload.name, description: payload.description, }; const res = await apiClient.post('/v1/api/execution/rules', body); return res.data; }; export const updateRule = async (payload: UpdateRuleRequest): Promise => { const validUserId = toValidUuid(payload.userId); await apiClient.put('/v1/api/execution/rules', { ruleId: toIntegerId(payload.ruleId), userId: validUserId, name: payload.name, description: payload.description, status: Number(payload.status), version: Number(payload.version || 1), ...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}), }); }; // ========================================== // 12. Audit Logs & System Telemetry APIs // ========================================== export const getAuditLogs = async (page = 1, pageSize = 50): Promise => { try { const res = await apiClient.post('/v1/api/governance/auditlog', { index: (page - 1) * pageSize, page, pageSize, }); if (res.data && Array.isArray(res.data)) { return res.data; } return []; } catch (err) { console.error('[getAuditLogs] API request failed:', err); return []; } }; export const getAuditLog = async (auditId: string | number): Promise => { try { const logs = await getAuditLogs(1, 100); return logs.find((l) => String(l.id) === String(auditId)) || null; } catch { return null; } };