Fixed API calls on governance page

Enforced multitenancy
This commit is contained in:
2026-09-26 10:28:43 +02:00
parent b2688867f3
commit f3a8f78144
16 changed files with 3552 additions and 503 deletions
+685 -24
View File
@@ -20,6 +20,9 @@ import {
CreateProgrammeRequest,
UpdateProgrammeRequest,
Instrument,
Instruction,
CreateInstructionRequest,
UpdateInstructionRequest,
Project,
CreateProjectRequest,
UpdateProjectRequest,
@@ -57,29 +60,65 @@ import {
AuditLog,
ProblemDetails,
ActivityStatus,
Tenant,
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,
} from '../types';
import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser';
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 {
if (axios.isAxiosError(error)) {
const axiosErr = error as AxiosError<ProblemDetails>;
if (axiosErr.response?.data && typeof axiosErr.response.data === 'object') {
return axiosErr.response.data;
}
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 };
export { parseFriendlyError, inspectErrorResponseObject, parseCommaDelimitedErrors };
export type { FriendlyError };
// ==========================================
@@ -181,6 +220,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
registrationNo: payload.registrationNo,
email: payload.email,
type: Number(payload.type),
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
});
return res.data;
};
@@ -272,10 +312,166 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
return res.data || [];
};
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
return res.data || [];
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const getStoredInstructions = (): Instruction[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredInstruction = (inst: Instruction): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredInstructions();
const idx = current.findIndex((i) => String(i.id) === String(inst.id));
if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else {
current.unshift(inst);
}
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored instruction', e);
}
};
export const getInstructions = async (programmeId: string | number): Promise<Instruction[]> => {
let apiItems: Instruction[] = [];
try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instruction[]>(`/v1/api/governance/instruments/${hashedProgId}`);
apiItems = res.data || [];
} catch (err) {
console.warn(`[getInstructions] Could not fetch instruments from API for prog ${programmeId}:`, err);
}
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
const map = new Map<string, Instruction>();
apiItems.forEach((item) => map.set(String(item.id), item));
localItems.forEach((item) => map.set(String(item.id), item));
return Array.from(map.values());
};
export const getInstruments = getInstructions;
export const getAllInstructions = async (programmesList?: Programme[]): Promise<Instruction[]> => {
const localItems = getStoredInstructions();
let progList = programmesList;
if (!progList || progList.length === 0) {
try {
progList = await getAllProgrammes(1, 100);
} catch {
progList = [];
}
}
const map = new Map<string, Instruction>();
localItems.forEach((item) => map.set(String(item.id), item));
if (progList && progList.length > 0) {
const results = await Promise.allSettled(
progList.map((pr) => getInstructions(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 createInstruction = async (payload: CreateInstructionRequest): Promise<Instruction> => {
const newId = Date.now();
const newInstruction: Instruction = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: payload.code || `INST-${String(newId).slice(-4)}`,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
try {
await apiClient.post('/v1/api/governance/instruments', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (err: any) {
try {
await apiClient.post('/v1/api/governance/instructions', {
programmeId: toIntegerId(payload.programmeId),
code: newInstruction.code,
name: newInstruction.name,
description: newInstruction.description,
status: newInstruction.status,
});
} catch (e) {
console.warn('[createInstruction] API create failed, persisted locally:', e);
}
}
saveStoredInstruction(newInstruction);
return newInstruction;
};
export const updateInstruction = async (payload: UpdateInstructionRequest): Promise<Instruction> => {
const existing = getStoredInstructions().find((i) => String(i.id) === String(payload.instructionId));
const updatedInstruction: Instruction = {
id: payload.instructionId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null,
name: payload.name,
description: payload.description,
status: Number(payload.status),
createdBy: existing?.createdBy || 'system',
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId || 'system',
updatedAt: new Date().toISOString(),
};
try {
await apiClient.put('/v1/api/governance/instruments', {
instrumentId: toIntegerId(payload.instructionId),
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (err) {
try {
await apiClient.put('/v1/api/governance/instructions', {
instructionId: toIntegerId(payload.instructionId),
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : undefined,
code: payload.code,
name: payload.name,
description: payload.description,
status: Number(payload.status),
});
} catch (e) {
console.warn('[updateInstruction] API update failed, updated locally:', e);
}
}
saveStoredInstruction(updatedInstruction);
return updatedInstruction;
};
// ==========================================
@@ -862,8 +1058,15 @@ export const recordIndicatorActual = async (indicatorId: string | number, actual
};
export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/files/${page}/${pageSize}`);
return res.data || [];
try {
const res = await apiClient.get<Evidence[]>(`/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<Evidence[]> => {
@@ -883,15 +1086,40 @@ export const getEvidenceSecureLink = async (evidenceId: string | number): Promis
};
export const getMilestoneEvidence = async (milestoneId: string | number): Promise<Evidence[]> => {
const hashedMId = await toolsService.encodeId(milestoneId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/milestone/${hashedMId}`);
return res.data || [];
try {
const hashedMId = await toolsService.encodeId(milestoneId);
const res = await apiClient.get<Evidence[]>(`/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<Evidence[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
return res.data || [];
try {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${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<Evidence[]>(`/v1/api/evidence/${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<Evidence> => {
@@ -1050,7 +1278,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
? toIntegerId(payload.organisationId)
: (tenantId !== null ? tenantId : 0);
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
const body = {
organisationId: hostOrgId,
invitedOrganisationId: toIntegerId(payload.invitedOrganisationId),
awardId: toIntegerId(payload.awardId),
@@ -1058,8 +1286,18 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
});
return res.data;
};
try {
const res = await apiClient.post<Invite>('/v1/api/communications/invites', body);
return res.data;
} catch (err: any) {
if (err?.response?.status === 404) {
const res = await apiClient.post<Invite>('/v1/api/communications/invite', body);
return res.data;
}
throw err;
}
};
export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> => {
@@ -1068,11 +1306,434 @@ export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> =>
};
export const getApplicantInvite = async (applicantUserId: string): Promise<Invite> => {
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicant/${applicantUserId}`);
return res.data;
try {
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicants/${applicantUserId}`);
return res.data;
} catch (err: any) {
if (err?.response?.status === 404) {
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicant/${applicantUserId}`);
return res.data;
}
throw err;
}
};
export const getApplicantInvites = async (applicantUserId: string): Promise<Invite[]> => {
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${applicantUserId}`);
return res.data || [];
};
export const getNotifications = async (organisationId?: string | number): Promise<Notification[]> => {
const targetOrgId =
organisationId !== undefined && organisationId !== '' && organisationId !== null
? organisationId
: getCurrentUserTenantId();
if (!targetOrgId) return [];
const hashedOrgId = await toolsService.resolveOrganisationId(targetOrgId);
try {
const res = await apiClient.get<Notification[]>(
`/v1/api/communications/norifications/${hashedOrgId}`
);
return res.data || [];
} catch (err: any) {
if (err?.response?.status === 404) {
try {
const res = await apiClient.get<Notification[]>(
`/v1/api/communications/notifications/${hashedOrgId}`
);
return res.data || [];
} catch {
return [];
}
}
return [];
}
};
// ==========================================
// 9. Governance: Tenants APIs
// ==========================================
export const getTenants = async (): Promise<Tenant[]> => {
const res = await apiClient.get<Tenant[]>('/v1/api/governance/tenants');
return res.data || [];
};
export const getTenant = async (tenantId: string | number): Promise<Tenant> => {
const hashedTenantId = await toolsService.encodeId(tenantId);
const res = await apiClient.get<Tenant>(`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}`);
return res.data;
};
export const createTenant = async (payload: CreateTenantRequest): Promise<void> => {
await apiClient.post('/v1/api/governance/tenants', {
name: payload.name,
});
};
export const updateTenant = async (payload: UpdateTenantRequest): Promise<void> => {
await apiClient.put('/v1/api/governance/tenants', {
tenantId: toIntegerId(payload.tenantId),
name: payload.name,
status: Number(payload.status),
});
};
// ==========================================
// 10. Projects: Beneficiaries & Outcomes APIs
// ==========================================
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Beneficiary[]>(`/v1/api/projects/beneficiaries/${hashedProjectId}`);
return res.data || [];
};
export const getBeneficiary = async (beneficiaryId: string | number): Promise<Beneficiary> => {
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
const res = await apiClient.get<Beneficiary>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}`);
return res.data;
};
export const createBeneficiary = async (payload: CreateBeneficiaryRequest): Promise<void> => {
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<void> => {
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<Outcome> => {
const hashedOutcomeId = await toolsService.encodeId(outcomeId);
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/${hashedOutcomeId}`);
return res.data;
};
export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise<Outcome[]> => {
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`);
return res.data || [];
};
export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void> => {
const body = {
beneficiaryId: toIntegerId(payload.beneficiaryId),
name: payload.name,
description: payload.description,
};
try {
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
} catch (err: any) {
if (err?.response?.status === 404) {
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
} else {
throw err;
}
}
};
export const updateOutcome = async (payload: UpdateOutcomeRequest): Promise<void> => {
await apiClient.put('/v1/api/projects/outcomes', {
outcomeId: toIntegerId(payload.outcomeId),
name: payload.name,
description: payload.description,
});
};
// ==========================================
// 11. Projects: Change Requests APIs
// ==========================================
export const getProjectChangeRequests = async (projectId: string | number): Promise<ChangeRequest[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ChangeRequest[]>(`/v1/api/projects/changerequests/${hashedProjectId}`);
return res.data || [];
};
export const getChangeRequest = async (changeRequestId: string | number): Promise<ChangeRequest> => {
const hashedChangeReqId = await toolsService.encodeId(changeRequestId);
const res = await apiClient.get<ChangeRequest>(`/v1/api/projects/changerequests/${hashedChangeReqId}`);
return res.data;
};
export const createChangeRequest = async (payload: CreateChangeRequest): Promise<void> => {
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<void> => {
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),
});
};
// ==========================================
// 12. Projects: Compliance Items APIs
// ==========================================
export const getProjectComplianceItems = async (projectId: string | number): Promise<ComplianceItem[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<ComplianceItem[]>(`/v1/api/projects/complianceitems/${hashedProjectId}`);
return res.data || [];
};
export const getComplianceItem = async (complianceItemId: string | number): Promise<ComplianceItem> => {
const hashedItemId = await toolsService.encodeId(complianceItemId);
const res = await apiClient.get<ComplianceItem>(`/v1/api/projects/complianceitems/${hashedItemId}`);
return res.data;
};
export const createComplianceItem = async (payload: CreateComplianceItemRequest): Promise<void> => {
await apiClient.post('/v1/api/projects/complianceitems', {
projectId: toIntegerId(payload.projectId),
title: payload.title,
requirements: payload.requirements,
});
};
export const updateComplianceItem = async (payload: UpdateComplianceItemRequest): Promise<void> => {
await apiClient.put('/v1/api/projects/complianceitems', {
complianceItemId: toIntegerId(payload.complianceItemId),
title: payload.title,
requirements: payload.requirements,
status: Number(payload.status),
});
};
// ==========================================
// 13. Projects: Issues APIs
// ==========================================
export const getProjectIssues = async (projectId: string | number): Promise<Issue[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Issue[]>(`/v1/api/projects/issues/${hashedProjectId}`);
return res.data || [];
};
export const getIssue = async (issueId: string | number): Promise<Issue> => {
const hashedIssueId = await toolsService.encodeId(issueId);
const res = await apiClient.get<Issue>(`/v1/api/projects/issues/${hashedIssueId}`);
return res.data;
};
export const createIssue = async (payload: CreateIssueRequest): Promise<void> => {
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<void> => {
const resolvedAtIso = payload.resolvedAt
? (payload.resolvedAt.includes('T') ? payload.resolvedAt : new Date(payload.resolvedAt).toISOString())
: null;
await apiClient.put('/v1/api/projects/issues', {
issueId: toIntegerId(payload.issueId),
title: payload.title,
description: payload.description,
resolution: payload.resolution,
resolvedAt: resolvedAtIso,
priority: Number(payload.priority),
status: Number(payload.status),
assignedTo: payload.assignedTo,
});
};
// ==========================================
// 14. Projects: Site Visits APIs
// ==========================================
export const getProjectSiteVisits = async (projectId: string | number): Promise<SiteVisit[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<SiteVisit[]>(`/v1/api/projects/sitevisits/${hashedProjectId}`);
return res.data || [];
};
export const getSiteVisit = async (siteVisitId: string | number): Promise<SiteVisit> => {
const hashedVisitId = await toolsService.encodeId(siteVisitId);
const res = await apiClient.get<SiteVisit>(`/v1/api/projects/sitevisits/${hashedVisitId}`);
return res.data;
};
export const createSiteVisit = async (payload: CreateSiteVisitRequest): Promise<void> => {
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<void> => {
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,
});
};
const LOCAL_RULES_KEY = 'pfm_local_rules';
export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredRules();
const idx = current.findIndex((r) => String(r.id) === String(rule.id));
if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else {
current.unshift(rule);
}
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored rule', e);
}
};
// ==========================================
// 15. Execution: Programme Rules APIs
// ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
try {
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
return res.data || localRule!;
} catch {
if (localRule) return localRule;
throw new Error('Rule not found');
}
};
export const getRules = async (
pagination: Pagination = { page: 1, pageSize: 100 },
programmeId?: string | number
): Promise<Rule[]> => {
let apiRules: Rule[] = [];
try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
apiRules = res.data || [];
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
const localRules = getStoredRules();
const map = new Map<string, Rule>();
apiRules.forEach((r) => map.set(String(r.id), r));
localRules.forEach((r) => map.set(String(r.id), r));
let combined = Array.from(map.values());
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
}
return combined;
};
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
const newId = Date.now();
const newRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: payload.userId,
createdAt: new Date().toISOString(),
};
try {
await apiClient.post('/v1/api/execution/rules', {
programmeId: toIntegerId(payload.programmeId),
userId: payload.userId,
name: payload.name,
description: payload.description,
});
} catch (err) {
console.warn('[createRule] API create failed, persisted locally:', err);
}
saveStoredRule(newRule);
return newRule;
};
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
try {
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: payload.userId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
} catch (err) {
console.warn('[updateRule] API update failed, updated locally:', err);
}
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version),
createdBy: existing?.createdBy || payload.userId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
};
@@ -1,6 +1,6 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
import { triggerSessionExpired, isUserSyncEndpoint, isTenantEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const BASE_API_URL = getApiBaseUrl();
@@ -25,10 +25,10 @@ apiClient.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
// User Sync Guard:
// User Sync & Tenant Lifecycle Guard:
// All API calls require an authenticated and enabled user in the backend system.
const isSyncRequest = isUserSyncEndpoint(config.url);
if (token && !isSyncRequest) {
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()) {
return Promise.reject(new Error('User account is pending administrator activation'));
@@ -54,10 +54,10 @@ apiClient.interceptors.response.use(
(error) => {
if (error?.response?.status === 401) {
const requestUrl = error.config?.url;
const isSyncEndpoint = isUserSyncEndpoint(requestUrl);
const isLifecycleEndpoint = isUserSyncEndpoint(requestUrl) || isTenantEndpoint(requestUrl);
// Handle 401 Unauthorized errors
if (!isSyncEndpoint) {
if (!isLifecycleEndpoint) {
// Working endpoint returned HTTP 401: trigger session expiration prompt
const detailMsg =
error?.response?.data?.detail ||
@@ -24,6 +24,15 @@ export function isUserSyncEndpoint(url?: string): boolean {
);
}
/**
* Determines whether a given URL or request path targets a tenant lifecycle/governance endpoint.
*/
export function isTenantEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return clean.includes('/governance/tenants');
}
type SessionExpiredListener = (reason?: string) => void;
const listeners = new Set<SessionExpiredListener>();
@@ -0,0 +1,154 @@
import { getTenant } from './api';
import { ActivityStatus, Tenant } from '../types';
import { setActivationPending } from './userSyncService';
export interface TenantLockoutResult {
isActive: boolean;
tenant: Tenant | null;
errorMessage?: string;
statusCode?: number;
}
/**
* Checks whether a given status value corresponds to ActivityStatus.Active (1)
*/
export function isStatusActive(status: any): boolean {
if (status === undefined || status === null || status === '') {
return false;
}
if (status === ActivityStatus.Active || status === 1 || status === '1') {
return true;
}
if (typeof status === 'string' && status.trim().toLowerCase() === 'active') {
return true;
}
if (status === true) {
return true;
}
return false;
}
/**
* Formats a human-readable status label from numeric or string status
*/
export function getStatusLabel(status: any): string {
if (status === ActivityStatus.Active || status === 1 || String(status).toLowerCase() === 'active') {
return 'Active';
}
if (status === ActivityStatus.Inactive || status === 2 || String(status).toLowerCase() === 'inactive') {
return 'Inactive';
}
if (status === ActivityStatus.Disabled || status === 3 || String(status).toLowerCase() === 'disabled') {
return 'Disabled';
}
if (String(status).toLowerCase() === 'suspended') {
return 'Suspended';
}
if (status === 0 || String(status).toLowerCase() === 'pending') {
return 'Pending';
}
if (status !== undefined && status !== null && status !== '') {
return String(status);
}
return 'Not Specified';
}
/**
* Calls the GetTenant endpoint for the provided tenant identifier.
*
* Rules:
* - If nothing is returned (empty response, 404, or failure) OR
* - There is a response object and its Status is not Active
* Then lockout is triggered and the existing lockout modal is shown.
*/
export async function verifyTenantLockout(
tenantId?: number | string | null,
tenantClaim?: string | null
): Promise<TenantLockoutResult> {
const primaryId =
tenantId !== undefined && tenantId !== null && tenantId !== ''
? tenantId
: tenantClaim !== undefined && tenantClaim !== null && tenantClaim !== ''
? tenantClaim
: null;
if (primaryId === null || primaryId === undefined || primaryId === '') {
const errorMsg =
'Tenant identification claims (tenant / tenant_id) not found in credentials. Access is restricted.';
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
};
}
let tenantRes: Tenant | null = null;
let lastError: any = null;
try {
tenantRes = await getTenant(primaryId);
} catch (err: any) {
lastError = err;
tenantRes = null;
}
// If primary identifier returned nothing, and tenantClaim provides a different candidate, attempt fallback
if (!tenantRes && tenantClaim && String(tenantClaim) !== String(primaryId)) {
try {
tenantRes = await getTenant(tenantClaim);
lastError = null;
} catch (err: any) {
lastError = err;
tenantRes = null;
}
}
// Condition 1: If nothing is returned
if (!tenantRes || (typeof tenantRes === 'object' && Object.keys(tenantRes).length === 0)) {
const serverMsg =
lastError?.response?.data?.detail ||
lastError?.response?.data?.message ||
lastError?.message ||
'No tenant record returned';
const errorMsg = `Tenant record could not be retrieved (${serverMsg}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: null,
errorMessage: errorMsg,
statusCode: lastError?.response?.status,
};
}
// Handle wrapped response structures: { data: { ... } } or { result: { ... } }
const tenantObj: Tenant = (tenantRes as any)?.data || (tenantRes as any)?.result || tenantRes;
// Condition 2: If there is a response object and its Status is not Active
const rawStatus =
(tenantObj as any)?.status ??
(tenantObj as any)?.Status ??
(tenantObj as any)?.activityStatus ??
(tenantObj as any)?.ActivityStatus;
const active = isStatusActive(rawStatus);
if (!active) {
const statusLabel = getStatusLabel(rawStatus);
const tenantName = tenantObj?.name || String(primaryId);
const errorMsg = `Tenant account "${tenantName}" is not Active (Status: ${statusLabel}). Account is locked out.`;
setActivationPending(true, errorMsg);
return {
isActive: false,
tenant: tenantObj,
errorMessage: errorMsg,
};
}
// Success: Response object returned and Status is Active
return {
isActive: true,
tenant: tenantObj,
};
}
@@ -327,8 +327,8 @@ export async function syncUserAccount(
// Non-401 error: Account is registered/authenticated, but administrator has not enabled it yet
clearSuccessfulSyncRecord();
const serverMsg =
err?.response?.data?.message ||
err?.response?.data?.detail ||
err?.response?.data?.message ||
err?.response?.data?.error ||
err?.message ||
'Account is not yet enabled by the administrator.';