import { api as client } from "@/auth/http"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { RuleEngineListMeta, RuleEngineListResult, RuleEngineRecord, RuleEngineResourceSlug, } from "@/types/rule-engine"; import { unwrap } from "@/utils/endpoint"; export interface RuleEngineListParams { search?: string; page?: number; pageSize?: number; isActive?: boolean; status?: string; sortBy?: string; sortOrder?: "ASC" | "DESC"; requiresDirectorApproval?: boolean; /** Rates category tabs — comma-separated appliesTo / trigger filters. */ appliesTo?: string; trigger?: string; } export interface RuleEngineReorderPayload { ids: string[]; requiresDirectorApproval?: boolean; } /** Priority-rule approval workflow (all priority-config changes go through it). */ const PRIORITY_RULE_CHANGES_BASE = "/priority-rule-change-requests"; export interface PriorityRuleChangeRequest { id: string; action: "CREATE" | "UPDATE" | "DELETE"; priorityConfigId: string | null; priorityConfig?: RuleEngineRecord | null; payload: Record | null; status: "PENDING" | "APPROVED" | "REJECTED"; requestedByUserId: string | null; decidedByUserId: string | null; decidedAt: string | null; decisionNote: string | null; createdAt: string; } export interface SubmitPriorityRuleChangePayload { action: "CREATE" | "UPDATE" | "DELETE"; priorityConfigId?: string; create?: Record; update?: Record; } /** Approval workflow for edits to LIVE rates — a DRAFT rate still edits directly. */ const RATE_CHANGES_BASE = "/rate-change-requests"; export interface RateChangeRequest { id: string; rateId: string; rate?: RuleEngineRecord | null; /** Changed fields only. */ payload: Record; /** What those same fields were when the change was filed. */ previousValues: Record; status: "PENDING" | "APPROVED" | "REJECTED"; requestedByUserId: string | null; decidedByUserId: string | null; decidedAt: string | null; decisionNote: string | null; createdAt: string; } export interface SubmitRateChangePayload { rateId: string; update: Record; } /** One selectable IAM position type, as returned by /approval-rules/position-types. */ export interface ApprovalPositionType { label: string; value: string; } const RESOURCE_BASE: Record = { "cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES, "container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES, "wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES, "priority-configs": URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIGS, "service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES, "weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES, yards: URL_CONSTANTS.RULE_ENGINE.YARDS, "yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES, "shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES, rates: URL_CONSTANTS.RULE_ENGINE.RATES, "approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES, }; const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => { switch (resource) { case "cargo-types": return URL_CONSTANTS.RULE_ENGINE.CARGO_TYPE_BY_ID(id); case "container-types": return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id); case "wagon-types": return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id); case "priority-configs": return URL_CONSTANTS.RULE_ENGINE.PRIORITY_CONFIG_BY_ID(id); case "service-types": return URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPE_BY_ID(id); case "weight-limit-rules": return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id); case "yards": return URL_CONSTANTS.RULE_ENGINE.YARD_BY_ID(id); case "yard-distances": return URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCE_BY_ID(id); case "shipping-lines": return URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINE_BY_ID(id); case "rates": return URL_CONSTANTS.RULE_ENGINE.RATE_BY_ID(id); case "approval-rules": return URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULE_BY_ID(id); default: return `${RESOURCE_BASE[resource]}/${id}`; } }; const defaultMeta = ( dataLength: number, page = 1, pageSize = 10, ): RuleEngineListMeta => { const totalPages = Math.max(1, Math.ceil(dataLength / pageSize)); return { total: dataLength, page, pageSize, totalPages, hasNextPage: page < totalPages, hasPreviousPage: page > 1, }; }; /** Standard envelope from the shared pagination toolkit: `{ items, meta }`. */ const isItemsEnvelope = ( value: unknown, ): value is RuleEngineListResult => Boolean(value) && typeof value === "object" && Array.isArray((value as { items?: unknown }).items); /** Legacy envelope (`{ data, meta }`) — still returned by wagon-types. */ const isLegacyEnvelope = ( value: unknown, ): value is { data: T[]; meta?: RuleEngineListMeta } => Boolean(value) && typeof value === "object" && Array.isArray((value as { data?: unknown }).data); const normalizeList = ( payload: unknown, page = 1, pageSize = 10, ): RuleEngineListResult => { const candidates: unknown[] = [payload, unwrap(payload as { data: unknown })]; for (const body of candidates) { if (isItemsEnvelope(body)) { return { items: body.items, meta: body.meta ?? defaultMeta(body.items.length, page, pageSize), }; } if (isLegacyEnvelope(body)) { return { items: body.data, meta: body.meta ?? defaultMeta(body.data.length, page, pageSize), }; } if (Array.isArray(body)) { return { items: body as T[], meta: defaultMeta(body.length, page, pageSize), }; } } return { items: [], meta: defaultMeta(0, page, pageSize) }; }; const normalizeEntity = (payload: unknown): T => { return unwrap(payload as { data: T }) as T; }; export const ruleEngineService = { list: async ( resource: RuleEngineResourceSlug, params?: RuleEngineListParams, ): Promise> => { const page = params?.page ?? 1; const pageSize = params?.pageSize ?? 10; const response = await client.get(RESOURCE_BASE[resource], { params: { page, pageSize, search: params?.search, isActive: params?.isActive, status: params?.status, sortBy: params?.sortBy, sortOrder: params?.sortOrder, requiresDirectorApproval: params?.requiresDirectorApproval, appliesTo: params?.appliesTo, trigger: params?.trigger, }, }); return normalizeList(response.data, page, pageSize); }, /** * Fetch every row of a resource by walking the pages. The API caps pageSize * at 100, so option/dropdown consumers that used to ask for 200-500 rows in * one shot go through here instead of getting silently capped (or a 400). */ listAll: async ( resource: RuleEngineResourceSlug, params?: Omit, ): Promise => { const pageSize = 100; const first = await ruleEngineService.list(resource, { ...params, page: 1, pageSize, }); const items = [...first.items]; const totalPages = first.meta.totalPages ?? 1; for (let page = 2; page <= totalPages; page += 1) { const next = await ruleEngineService.list(resource, { ...params, page, pageSize, }); items.push(...next.items); } return items; }, getById: async ( resource: RuleEngineResourceSlug, id: string, ): Promise => { const response = await client.get(byIdPath(resource, id)); return normalizeEntity(response.data); }, create: async ( resource: RuleEngineResourceSlug, payload: Record, ): Promise => { const response = await client.post(RESOURCE_BASE[resource], payload); return normalizeEntity(response.data); }, update: async ( resource: RuleEngineResourceSlug, id: string, payload: Record, ): Promise => { const response = await client.patch(byIdPath(resource, id), payload); return normalizeEntity(response.data); }, remove: async ( resource: RuleEngineResourceSlug, id: string, ): Promise => { await client.delete(byIdPath(resource, id)); }, reorder: async ( resource: RuleEngineResourceSlug, payload: RuleEngineReorderPayload, ): Promise => { await client.post(`${RESOURCE_BASE[resource]}/reorder`, payload); }, moveOrder: async ( resource: RuleEngineResourceSlug, id: string, direction: "up" | "down", ): Promise => { await client.post(`${byIdPath(resource, id)}/move-order`, { direction }); }, submitRate: async (id: string): Promise => { const response = await client.post( URL_CONSTANTS.RULE_ENGINE.RATE_SUBMIT(id), ); return normalizeEntity(response.data); }, approveRate: async (id: string): Promise => { const response = await client.post( URL_CONSTANTS.RULE_ENGINE.RATE_APPROVE(id), ); return normalizeEntity(response.data); }, /** File a priority-rule change (create/update/delete) for approval. */ submitPriorityRuleChange: async ( payload: SubmitPriorityRuleChangePayload, ): Promise => { const response = await client.post(PRIORITY_RULE_CHANGES_BASE, payload); return unwrap(response.data) as PriorityRuleChangeRequest; }, listPriorityRuleChanges: async ( status?: PriorityRuleChangeRequest["status"], ): Promise => { const response = await client.get(PRIORITY_RULE_CHANGES_BASE, { params: status ? { status } : undefined, }); const body = unwrap(response.data) as unknown; return Array.isArray(body) ? (body as PriorityRuleChangeRequest[]) : []; }, approvePriorityRuleChange: async ( id: string, decisionNote?: string, ): Promise => { const response = await client.post( `${PRIORITY_RULE_CHANGES_BASE}/${id}/approve`, { decisionNote }, ); return unwrap(response.data) as PriorityRuleChangeRequest; }, rejectPriorityRuleChange: async ( id: string, decisionNote?: string, ): Promise => { const response = await client.post( `${PRIORITY_RULE_CHANGES_BASE}/${id}/reject`, { decisionNote }, ); return unwrap(response.data) as PriorityRuleChangeRequest; }, /** Propose a change to a LIVE rate — it stays at its current value until approved. */ submitRateChange: async ( payload: SubmitRateChangePayload, ): Promise => { const response = await client.post(RATE_CHANGES_BASE, payload); return unwrap(response.data) as RateChangeRequest; }, listRateChanges: async ( status?: RateChangeRequest["status"], ): Promise => { const response = await client.get(RATE_CHANGES_BASE, { params: status ? { status } : undefined, }); const body = unwrap(response.data) as unknown; return Array.isArray(body) ? (body as RateChangeRequest[]) : []; }, approveRateChange: async ( id: string, decisionNote?: string, ): Promise => { const response = await client.post(`${RATE_CHANGES_BASE}/${id}/approve`, { decisionNote, }); return unwrap(response.data) as RateChangeRequest; }, rejectRateChange: async ( id: string, decisionNote?: string, ): Promise => { const response = await client.post(`${RATE_CHANGES_BASE}/${id}/reject`, { decisionNote, }); return unwrap(response.data) as RateChangeRequest; }, /** * IAM position types that an approval step can require/block. Replaces the * old hardcoded LINE_STAFF/DIRECTOR/CEO triple — the chain is configured from * whatever positions IAM actually defines. */ getApprovalPositionTypes: async (): Promise => { const response = await client.get( URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_POSITION_TYPES, ); const body = unwrap(response.data) as unknown; if (Array.isArray(body)) return body as ApprovalPositionType[]; if ( body && typeof body === "object" && "data" in body && Array.isArray((body as { data: unknown }).data) ) { return (body as { data: ApprovalPositionType[] }).data; } return []; }, getApprovalChain: async ( requiresDirectorApproval = true, ): Promise => { const response = await client.get( URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_CHAIN, { params: { requiresDirectorApproval }, }, ); const body = unwrap(response.data) as unknown; if (Array.isArray(body)) return body as RuleEngineRecord[]; if ( body && typeof body === "object" && "data" in body && Array.isArray((body as { data: unknown }).data) ) { return (body as { data: RuleEngineRecord[] }).data; } return []; }, };