/** * Typed API client. All requests go through `apiFetch`, which: * - Attaches the Authorization header from the stored access token. * - Parses error bodies into ApiRequestError. * - Returns typed data (no `any`). * * This is the reference implementation — add one function per endpoint group. */ import { ApiRequestError } from '@/services/errors' import type { LoginRequest, LeaveWishCreateRequest, LeaveWishScope, LeaveWishUpdateRequest, ManualFetchRequest, ParentalLeaveFetch, RegisterRequest, TokenResponse, VacationDays, VacationDaysCreateRequest, WorkspaceCreateRequest, WorkspaceUpdateRequest, WorkspaceOverview, PlanSyncResponse, PreschoolPlanningDaysPlannedRequest, PreschoolPlanningDaysTakenRequest, CreateInvitationResponse, } from '@/types' import type { Workspace, LeaveWish, PlanProposal, PlanChange, CostBreakdown, Tip, PlannedLeaveDay, TierData, Age4Projection, Child, PersonalData, CalendarWindow, CalendarDayEntry, VacationYearBalance, ParentalLeaveYearBalance, ParentYearBalance, YearTotals, PreschoolPlanningDays, Invitation, } from '@/types' const BASE = '/api' // --------------------------------------------------------------------------- // Wire format converters (snake_case → camelCase) // --------------------------------------------------------------------------- export interface RawTierData { sjukpenning_total: number sjukpenning_used: number sjukpenning_left: number lagsta_niva_total: number lagsta_niva_used: number lagsta_niva_left: number } function toTierData(raw: RawTierData): TierData { return { sjukpenningTotal: raw.sjukpenning_total, sjukpenningUsed: raw.sjukpenning_used, sjukpenningLeft: raw.sjukpenning_left, lagstaNivaTotal: raw.lagsta_niva_total, lagstaNivaUsed: raw.lagsta_niva_used, lagstaNivaLeft: raw.lagsta_niva_left, } } export interface RawParentalLeaveFetch { id: string personal_data_uuid: string workspace_id: string child_id: string source: 'scraped' | 'manual' | 'deduced' fetched_at: string total_days: number days_used: number days_left: number reserved_days_total: number reserved_days_left: number transferable_days_left: number transferable_days_reported: boolean dubbeldagar_left: number tier_data: RawTierData created_at: string updated_at: string } function toParentalLeaveFetch(raw: RawParentalLeaveFetch): ParentalLeaveFetch { return { id: raw.id, personalDataUuid: raw.personal_data_uuid, workspaceId: raw.workspace_id, childId: raw.child_id, source: raw.source, fetchedAt: raw.fetched_at, totalDays: raw.total_days, daysUsed: raw.days_used, daysLeft: raw.days_left, reservedDaysTotal: raw.reserved_days_total, reservedDaysLeft: raw.reserved_days_left, transferableDaysLeft: raw.transferable_days_left, transferableDaysReported: raw.transferable_days_reported, dubbeldagarLeft: raw.dubbeldagar_left, tierData: toTierData(raw.tier_data), createdAt: raw.created_at, updatedAt: raw.updated_at, } } export interface RawVacationYearBalance { year: number earned: number is_assumed: boolean carried_in: number planned_use: number left: number carried_out: number expiring: number expiring_age: number expiring_over_cap: number } function toVacationYearBalance(raw: RawVacationYearBalance): VacationYearBalance { return { year: raw.year, earned: raw.earned, isAssumed: raw.is_assumed, carriedIn: raw.carried_in, plannedUse: raw.planned_use, left: raw.left, carriedOut: raw.carried_out, expiring: raw.expiring, expiringAge: raw.expiring_age, expiringOverCap: raw.expiring_over_cap, } } export interface RawParentalLeaveYearBalance { year: number sjukpenning_used: number lagsta_used: number sjukpenning_left: number lagsta_left: number } function toParentalLeaveYearBalance( raw: RawParentalLeaveYearBalance, ): ParentalLeaveYearBalance { return { year: raw.year, sjukpenningUsed: raw.sjukpenning_used, lagstaUsed: raw.lagsta_used, sjukpenningLeft: raw.sjukpenning_left, lagstaLeft: raw.lagsta_left, } } export interface RawParentYearBalance { personal_data_uuid: string child_id: string year: number vacation: RawVacationYearBalance parental_leave: RawParentalLeaveYearBalance } function toParentYearBalance(raw: RawParentYearBalance): ParentYearBalance { return { personalDataUuid: raw.personal_data_uuid, childId: raw.child_id, year: raw.year, vacation: toVacationYearBalance(raw.vacation), parentalLeave: toParentalLeaveYearBalance(raw.parental_leave), } } export interface RawYearTotals { year: number pl_left: number vacation_left: number } function toYearTotals(raw: RawYearTotals): YearTotals { return { year: raw.year, plLeft: raw.pl_left, vacationLeft: raw.vacation_left, } } export interface RawAge4Projection { personal_data_uuid: string child_id: string birth_date: string fourth_birthday: string total_days: number days_used: number planned_days_before_b4: number projected_left_at_b4: number savable_after_b4: number expiring_at_b4: number savable_cap_at_b4: number year_balances: RawParentYearBalance[] } function toAge4Projection(raw: RawAge4Projection): Age4Projection { return { personalDataUuid: raw.personal_data_uuid, childId: raw.child_id, birthDate: raw.birth_date, fourthBirthday: raw.fourth_birthday, totalDays: raw.total_days, daysUsed: raw.days_used, plannedDaysBeforeB4: raw.planned_days_before_b4, projectedLeftAtB4: raw.projected_left_at_b4, savableAfterB4: raw.savable_after_b4, expiringAtB4: raw.expiring_at_b4, savableCapAtB4: raw.savable_cap_at_b4, yearBalances: raw.year_balances.map(toParentYearBalance), } } export interface RawWorkspaceOverview { workspace_id: string child_id: string parent_projections: RawAge4Projection[] combined_days_used: number combined_days_left: number combined_expiring_at_b4: number year_totals: RawYearTotals[] } function toWorkspaceOverview(raw: RawWorkspaceOverview): WorkspaceOverview { return { workspaceId: raw.workspace_id, childId: raw.child_id, parentProjections: raw.parent_projections.map(toAge4Projection), combinedDaysUsed: raw.combined_days_used, combinedDaysLeft: raw.combined_days_left, combinedExpiringAtB4: raw.combined_expiring_at_b4, yearTotals: raw.year_totals.map(toYearTotals), } } interface RawVacationDays { id: string personal_data_uuid: string workspace_id: string year: number total_days: number saved_days_in: number created_at: string | null updated_at: string | null } function toVacationDays(raw: RawVacationDays): VacationDays { return { id: raw.id, personalDataUuid: raw.personal_data_uuid, workspaceId: raw.workspace_id, year: raw.year, totalDays: raw.total_days, savedDaysIn: raw.saved_days_in, createdAt: raw.created_at, updatedAt: raw.updated_at, } } interface RawPlannedLeaveDay { id: string workspace_id: string child_id: string parent_personal_data_uuid: string date: string day_type: 'sjukpenning' | 'lagsta' | 'vacation' is_dubbeldag: boolean wish_id: string | null is_pinned: boolean created_at: string updated_at: string } function toPlannedLeaveDay(raw: RawPlannedLeaveDay): PlannedLeaveDay { return { id: raw.id, workspaceId: raw.workspace_id, childId: raw.child_id, parentPersonalDataUuid: raw.parent_personal_data_uuid, date: raw.date, dayType: raw.day_type, isDubbeldag: raw.is_dubbeldag, wishId: raw.wish_id, isPinned: raw.is_pinned, createdAt: raw.created_at, updatedAt: raw.updated_at, } } export interface RawTip { id: string severity: 'info' | 'warning' | 'action' message_key: string data: Record anchor_date: string | null anchor_range: [string, string] | null } function toTip(raw: RawTip): Tip { return { id: raw.id, severity: raw.severity, messageKey: raw.message_key, data: raw.data, anchorDate: raw.anchor_date, anchorRange: raw.anchor_range, } } interface RawLeaveWish { id: string workspace_id: string child_id: string | null name: string name_message_key: string | null window_start_month: number window_start_day: number window_end_month: number window_end_day: number target_weeks: number target_days?: number | null first_year: number last_year: number | null series_id: string | null priority: number parent_mode: 'either' | 'both' | 'specific' parent_personal_data_uuid: string | null window_mode: 'fixed' | 'flexible' wants_dubbeldag?: boolean created_at: string | null updated_at: string | null } function toLeaveWish(raw: RawLeaveWish): LeaveWish { return { id: raw.id, workspaceId: raw.workspace_id, childId: raw.child_id, name: raw.name, nameMessageKey: raw.name_message_key, windowStartMonth: raw.window_start_month, windowStartDay: raw.window_start_day, windowEndMonth: raw.window_end_month, windowEndDay: raw.window_end_day, targetWeeks: raw.target_weeks, targetDays: raw.target_days ?? null, firstYear: raw.first_year, seriesId: raw.series_id ?? null, lastYear: raw.last_year, priority: raw.priority, parentMode: raw.parent_mode, parentPersonalDataUuid: raw.parent_personal_data_uuid, windowMode: raw.window_mode ?? 'fixed', wantsDubbeldag: raw.wants_dubbeldag ?? false, createdAt: raw.created_at, updatedAt: raw.updated_at, } } interface RawProposedDay { date: string parent_personal_data_uuid: string child_id: string | null day_type: 'free' | 'vacation' | 'sjukpenning' | 'lagsta' is_dubbeldag: boolean wish_id: string reason: string } interface RawShortfallRemedy { kind: string message_key: string data: Record days_freed: number } interface RawWindowOutcome { wish_id: string wish_name: string wish_name_message_key: string | null year: number start_date: string end_date: string weeks_wished: number weeks_funded: number shortfall_days: number strategy: string message_key: string data: Record cost_breakdown: Record remedies: RawShortfallRemedy[] } interface RawPlanWarning { kind: string message_key: string data: Record } interface RawPoolBalance { parent_personal_data_uuid: string child_id: string sjukpenning_left: number lagsta_left: number dubbeldagar_left: number } interface RawVacationBalance { parent_personal_data_uuid: string year: number total_days: number saved_days_in: number already_used: number is_assumed: boolean } interface RawSurplusAdvice { kind: string message_key: string days_involved: number data: Record } interface RawPlanAge4Projection { parent_personal_data_uuid: string child_id: string fourth_birthday: string projected_left_at_b4: number savable_after_b4: number expiring_at_b4: number } interface RawPlanProposal { days: RawProposedDay[] outcomes: RawWindowOutcome[] warnings: RawPlanWarning[] pool_balances: RawPoolBalance[] vacation_balances: RawVacationBalance[] surplus_advice: RawSurplusAdvice[] age4_projections: RawPlanAge4Projection[] } function toCostBreakdown(raw: Record): CostBreakdown { return { free: raw.free ?? 0, vacation: raw.vacation ?? 0, sjukpenning: raw.sjukpenning ?? 0, lagsta: raw.lagsta ?? 0, } } function toPlanProposal(raw: RawPlanProposal): PlanProposal { return { days: raw.days.map((d) => ({ date: d.date, parentPersonalDataUuid: d.parent_personal_data_uuid, childId: d.child_id, dayType: d.day_type, isDubbeldag: d.is_dubbeldag, wishId: d.wish_id, reason: d.reason, })), outcomes: raw.outcomes.map((o) => ({ wishId: o.wish_id, wishName: o.wish_name, wishNameMessageKey: o.wish_name_message_key, year: o.year, startDate: o.start_date, endDate: o.end_date, weeksWished: o.weeks_wished, weeksFunded: o.weeks_funded, shortfallDays: o.shortfall_days, strategy: o.strategy, messageKey: o.message_key, data: o.data, costBreakdown: toCostBreakdown(o.cost_breakdown), remedies: o.remedies.map((r) => ({ kind: r.kind, messageKey: r.message_key, data: r.data, daysFreed: r.days_freed, })), })), warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })), poolBalances: raw.pool_balances.map((p) => ({ parentPersonalDataUuid: p.parent_personal_data_uuid, childId: p.child_id, sjukpenningLeft: p.sjukpenning_left, lagstaLeft: p.lagsta_left, dubbeldagarLeft: p.dubbeldagar_left, })), vacationBalances: raw.vacation_balances.map((v) => ({ parentPersonalDataUuid: v.parent_personal_data_uuid, year: v.year, totalDays: v.total_days, savedDaysIn: v.saved_days_in, alreadyUsed: v.already_used, isAssumed: v.is_assumed, })), surplusAdvice: raw.surplus_advice.map((a) => ({ kind: a.kind, messageKey: a.message_key, daysInvolved: a.days_involved, data: a.data, })), age4Projections: raw.age4_projections.map((p) => ({ parentPersonalDataUuid: p.parent_personal_data_uuid, childId: p.child_id, fourthBirthday: p.fourth_birthday, projectedLeftAtB4: p.projected_left_at_b4, savableAfterB4: p.savable_after_b4, expiringAtB4: p.expiring_at_b4, })), } } interface RawPersonalData { uuid: string workspace_id: string name: string salary_type: 'monthly' | 'hourly' employment_status: 'employed' | 'jobseeking' | 'none' created_at: string | null } function toPersonalData(raw: RawPersonalData): PersonalData { return { uuid: raw.uuid, workspaceId: raw.workspace_id, name: raw.name, salaryType: raw.salary_type, employmentStatus: raw.employment_status, createdAt: raw.created_at ?? '', } } interface RawWorkspace { id: string name: string created_at: string take_pl_on_red_days: boolean } function toWorkspace(raw: RawWorkspace): Workspace { return { id: raw.id, name: raw.name, createdAt: raw.created_at, takePlOnRedDays: raw.take_pl_on_red_days, } } interface RawChild { id: string workspace_id: string name: string birth_date: string preschool_start_date: string | null preschool_planning_days_per_year: number created_at: string | null } function toChild(raw: RawChild): Child { return { id: raw.id, workspaceId: raw.workspace_id, name: raw.name, birthDate: raw.birth_date, preschoolStartDate: raw.preschool_start_date, preschoolPlanningDaysPerYear: raw.preschool_planning_days_per_year, createdAt: raw.created_at ?? '', } } interface RawDayOverride { id: string workspace_id: string date: string is_working_day: boolean note: string | null created_at: string | null updated_at: string | null } function toDayOverride(raw: RawDayOverride): import('@/types').DayOverride { return { id: raw.id, workspaceId: raw.workspace_id, date: raw.date, isWorkingDay: raw.is_working_day, note: raw.note, createdAt: raw.created_at, updatedAt: raw.updated_at, } } interface RawPlannedDayExclusion { id: string workspace_id: string child_id: string parent_personal_data_uuid: string date: string created_at: string | null updated_at: string | null } function toPlannedDayExclusion( raw: RawPlannedDayExclusion, ): import('@/types').PlannedDayExclusion { return { id: raw.id, workspaceId: raw.workspace_id, childId: raw.child_id, parentPersonalDataUuid: raw.parent_personal_data_uuid, date: raw.date, createdAt: raw.created_at, updatedAt: raw.updated_at, } } let _accessToken: string | null = null export function setAccessToken(token: string | null): void { _accessToken = token } // Endpoints that must never trigger a refresh-and-retry on 401 — retrying // them would either be nonsensical (the refresh call retrying itself) or // mask the real "not logged in" outcome (login/logout). const AUTH_NO_RETRY_PATHS = new Set(['/auth/login', '/auth/refresh', '/auth/logout']) // Dedupe concurrent 401s (e.g. Dashboard's ~7 parallel requests all // expiring at once) into a single in-flight refresh call, shared across // every apiFetch caller. let refreshPromise: Promise | null = null // AuthContext registers this so a refresh-and-retry failure can log the // user out immediately, instead of leaving `isAuthenticated: true` while // every subsequent request 401s. Kept out of this module so api.ts stays // framework-agnostic. let onUnauthorized: (() => void) | null = null export function setUnauthorizedHandler(handler: (() => void) | null): void { onUnauthorized = handler } async function apiFetch( path: string, options: RequestInit = {}, isRetry = false, ): Promise { const headers: Record = { 'Content-Type': 'application/json', ...(options.headers as Record | undefined), } if (_accessToken) { headers['Authorization'] = `Bearer ${_accessToken}` } const response = await fetch(`${BASE}${path}`, { ...options, headers }) if (response.status === 401 && !isRetry && !AUTH_NO_RETRY_PATHS.has(path)) { let refreshed: TokenResponse | null = null try { refreshPromise ??= refreshToken().finally(() => { refreshPromise = null }) refreshed = await refreshPromise } catch { // Refresh itself failed (expired/invalid refresh cookie, or a // network error) — clear the token and let the caller's original // 401 surface below so AuthContext can log the user out. setAccessToken(null) onUnauthorized?.() } if (refreshed) { setAccessToken(refreshed.access_token) // Retry exactly once, outside the try/catch above — `isRetry: true` // means a 401 on this attempt is final and must propagate as-is // rather than being caught here and re-reported as the original // (now stale) 401. return await apiFetch(path, options, true) } } if (!response.ok) { let error: { code: string; message: string; details?: Record } = { code: String(response.status), message: response.statusText, } try { const body = (await response.json()) as { detail?: | string | Array<{ msg?: string; loc?: unknown[] }> | { message_key: string; data?: Record } code?: string message?: string } if ( body.detail && typeof body.detail === 'object' && !Array.isArray(body.detail) && 'message_key' in body.detail ) { // Structured `{message_key, data}` error (see workspace.py's // exception mapping) — kept out of `message` so callers never // accidentally render the raw key; `details.messageKey`/`data` is // the contract callers (e.g. Settings.tsx's `describePartnerError`) // read instead. const structured = body.detail error = { code: structured.message_key, message: structured.message_key, details: { messageKey: structured.message_key, data: structured.data ?? {} }, } } else { const detail = Array.isArray(body.detail) ? body.detail.map((e) => e.msg ?? JSON.stringify(e)).join('; ') : body.detail // FastAPI's default pydantic 422 shape puts the offending field name // in each error's `loc` (e.g. ['body', 'sjukpenning_left']), not in // `msg` — surface the last segment of each `loc` as `details.fields` // so a caller (e.g. ParentBalanceForm) can map the failure back to // one input instead of only showing the raw message. const fields = Array.isArray(body.detail) ? body.detail .map((e) => (Array.isArray(e.loc) ? e.loc[e.loc.length - 1] : undefined)) .filter((f): f is string => typeof f === 'string') : [] error = { code: body.code ?? String(response.status), message: detail ?? body.message ?? response.statusText, ...(fields.length > 0 ? { details: { fields } } : {}), } } } catch { // response body wasn't JSON — use status text } throw new ApiRequestError(response.status, error) } if (response.status === 204) return undefined as T return response.json() as Promise } // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- export async function register(req: RegisterRequest): Promise { return apiFetch('/auth/register', { method: 'POST', body: JSON.stringify({ email: req.email, password: req.password, display_name: req.displayName, // Omitted (rather than sent as `undefined`) so the backend's // "no invite" default (`None`) applies untouched. ...(req.inviteToken !== undefined ? { invite_token: req.inviteToken } : {}), }), }) } export async function login(req: LoginRequest): Promise { return apiFetch('/auth/login', { method: 'POST', body: JSON.stringify({ email: req.email, password: req.password }), }) } export async function logout(): Promise { return apiFetch('/auth/logout', { method: 'POST' }) } export async function refreshToken(): Promise { return apiFetch('/auth/refresh', { method: 'POST' }) } export interface UserContext { personal_data_uuid: string workspace_id: string parent_name: string children: Array<{ id: string; name: string; birth_date: string }> } export async function getUserContext(): Promise { return apiFetch('/auth/context') } export interface CreateChildRequest { workspace_id: string name: string birth_date: string } export async function createChild(req: CreateChildRequest): Promise<{ id: string; name: string }> { return apiFetch('/children', { method: 'POST', body: JSON.stringify(req), }) } export async function getChild(childId: string): Promise { const raw = (await apiFetch(`/children/${childId}`)) as RawChild return toChild(raw) } // --------------------------------------------------------------------------- // Workspace // --------------------------------------------------------------------------- export async function createWorkspace(req: WorkspaceCreateRequest): Promise { return apiFetch('/workspace', { method: 'POST', body: JSON.stringify({ name: req.name }) }) } export async function getWorkspace(workspaceId: string): Promise { const raw = (await apiFetch(`/workspace/${workspaceId}`)) as RawWorkspace return toWorkspace(raw) } export async function updateWorkspace( workspaceId: string, req: WorkspaceUpdateRequest, ): Promise { const raw = (await apiFetch(`/workspace/${workspaceId}`, { method: 'PUT', body: JSON.stringify({ name: req.name, take_pl_on_red_days: req.takePlOnRedDays, }), })) as RawWorkspace return toWorkspace(raw) } export async function addPartner( workspaceId: string, displayName: string, ): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> { return apiFetch(`/workspace/${workspaceId}/parents`, { method: 'POST', body: JSON.stringify({ display_name: displayName }), }) } // --------------------------------------------------------------------------- // Vacation days (reference endpoint group — mirror this pattern for others) // --------------------------------------------------------------------------- export async function upsertVacationDays(req: VacationDaysCreateRequest): Promise { return apiFetch('/vacation-days', { method: 'POST', body: JSON.stringify({ personal_data_uuid: req.personalDataUuid, workspace_id: req.workspaceId, year: req.year, total_days: req.totalDays, saved_days_in: req.savedDaysIn ?? 0, }), }) } export async function getVacationDaysByWorkspace(workspaceId: string): Promise { const raw = (await apiFetch(`/vacation-days/workspace/${workspaceId}`)) as RawVacationDays[] return raw.map(toVacationDays) } // --------------------------------------------------------------------------- // Parental leave // --------------------------------------------------------------------------- export async function saveManualFetch(req: ManualFetchRequest): Promise { // Optional keys are omitted entirely (rather than sent as `undefined`, // which `JSON.stringify` would drop anyway but less explicitly) so the // backend's "null/omitted = derive it" defaulting kicks in — see // `ManualFetchRequest`'s doc comment. const body: Record = { personal_data_uuid: req.personalDataUuid, workspace_id: req.workspaceId, child_id: req.childId, sjukpenning_left: req.sjukpenningLeft, lagsta_niva_left: req.lagstaNivaLeft, dubbeldagar_left: req.dubbeldagarLeft, } if (req.reservedDaysLeft !== undefined) body.reserved_days_left = req.reservedDaysLeft if (req.totalDays !== undefined) body.total_days = req.totalDays if (req.source !== undefined) body.source = req.source const raw = (await apiFetch('/parental-leave/manual', { method: 'POST', body: JSON.stringify(body), })) as RawParentalLeaveFetch return toParentalLeaveFetch(raw) } // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- export async function getWorkspaceOverview( workspaceId: string, childId: string, ): Promise { const raw = (await apiFetch(`/analytics/${workspaceId}/overview?child_id=${childId}`)) as RawWorkspaceOverview return toWorkspaceOverview(raw) } // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- interface ParentSettingsUpdate { salary_type?: string | undefined employment_status?: string | undefined } export async function updateParentSettings( personalDataUuid: string, payload: ParentSettingsUpdate, ): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> { return apiFetch(`/settings/parent/${personalDataUuid}`, { method: 'PUT', body: JSON.stringify(payload), }) } interface ChildSettingsUpdate { birth_date?: string preschool_start_date?: string preschool_planning_days_per_year?: number } export async function updateChildSettings( childId: string, payload: ChildSettingsUpdate, ): Promise { const raw = (await apiFetch(`/settings/child/${childId}`, { method: 'PUT', body: JSON.stringify(payload), })) as RawChild return toChild(raw) } // --------------------------------------------------------------------------- // Leave wishes ("N weeks off inside this yearly window") + planning engine // --------------------------------------------------------------------------- export async function getLeaveWishes(workspaceId: string): Promise { const raw = (await apiFetch(`/leave-wishes/${workspaceId}`)) as RawLeaveWish[] return raw.map(toLeaveWish) } export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise { const raw = (await apiFetch('/leave-wishes', { method: 'POST', body: JSON.stringify({ workspace_id: payload.workspaceId, child_id: payload.childId ?? null, name: payload.name, name_message_key: payload.nameMessageKey ?? null, window_start_month: payload.windowStartMonth, window_start_day: payload.windowStartDay, window_end_month: payload.windowEndMonth, window_end_day: payload.windowEndDay, target_weeks: payload.targetWeeks, target_days: payload.targetDays ?? null, recurrence: payload.recurrence, first_year: payload.firstYear, last_year: payload.lastYear ?? null, priority: payload.priority ?? 1, parent_mode: payload.parentMode ?? 'either', parent_personal_data_uuid: payload.parentPersonalDataUuid ?? null, window_mode: payload.windowMode ?? 'fixed', series_id: payload.seriesId ?? null, wants_dubbeldag: payload.wantsDubbeldag ?? false, }), })) as RawLeaveWish return toLeaveWish(raw) } /** `scope` decides how far the edit reaches on a repeating wish — see * `LeaveWishScope`. Omitted means "this year only", matching the backend's * own default, so existing single-occurrence callers need no change. */ export async function updateLeaveWish( id: string, payload: LeaveWishUpdateRequest, scope: LeaveWishScope = 'occurrence', ): Promise { const body: Record = {} if (payload.childId !== undefined) body.child_id = payload.childId if (payload.name !== undefined) body.name = payload.name if (payload.nameMessageKey !== undefined) body.name_message_key = payload.nameMessageKey if (payload.windowStartMonth !== undefined) body.window_start_month = payload.windowStartMonth if (payload.windowStartDay !== undefined) body.window_start_day = payload.windowStartDay if (payload.windowEndMonth !== undefined) body.window_end_month = payload.windowEndMonth if (payload.windowEndDay !== undefined) body.window_end_day = payload.windowEndDay if (payload.targetWeeks !== undefined) body.target_weeks = payload.targetWeeks if (payload.targetDays !== undefined) body.target_days = payload.targetDays if (payload.recurrence !== undefined) body.recurrence = payload.recurrence if (payload.firstYear !== undefined) body.first_year = payload.firstYear if (payload.lastYear !== undefined) body.last_year = payload.lastYear if (payload.priority !== undefined) body.priority = payload.priority if (payload.parentMode !== undefined) body.parent_mode = payload.parentMode if (payload.parentPersonalDataUuid !== undefined) { body.parent_personal_data_uuid = payload.parentPersonalDataUuid } if (payload.windowMode !== undefined) body.window_mode = payload.windowMode if (payload.wantsDubbeldag !== undefined) body.wants_dubbeldag = payload.wantsDubbeldag const raw = (await apiFetch(`/leave-wishes/${id}?scope=${scope}`, { method: 'PUT', body: JSON.stringify(body), })) as RawLeaveWish return toLeaveWish(raw) } /** `scope: 'series'` removes every year of a repeating wish; the default * removes just this one year's row. */ export async function deleteLeaveWish( id: string, scope: LeaveWishScope = 'occurrence', ): Promise { return apiFetch(`/leave-wishes/${id}?scope=${scope}`, { method: 'DELETE' }) } /** Read-only preview of the planning engine's proposal — persists nothing. */ export async function proposePlan(workspaceId: string): Promise { const raw = (await apiFetch('/plan/propose', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }), })) as RawPlanProposal return toPlanProposal(raw) } interface RawDayCollision { date: string parent_personal_data_uuid: string child_id: string existing_day_type: string proposed_day_type: string } interface RawRejection { message_key: string data: Record } interface RawPlanApplyResponse { accepted: boolean created_count: number skipped_identical_count: number skipped_free_count: number replaced_count: number collisions: RawDayCollision[] rejections: RawRejection[] created_days: object[] } function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanApplyResponse { return { accepted: raw.accepted, createdCount: raw.created_count, skippedIdenticalCount: raw.skipped_identical_count, skippedFreeCount: raw.skipped_free_count, replacedCount: raw.replaced_count, collisions: raw.collisions.map((c) => ({ date: c.date, parentPersonalDataUuid: c.parent_personal_data_uuid, childId: c.child_id, existingDayType: c.existing_day_type, proposedDayType: c.proposed_day_type, })), rejections: raw.rejections.map((r) => ({ messageKey: r.message_key, data: r.data })), createdDays: raw.created_days, } } interface RawPlanChange { kind: string date: string parent_personal_data_uuid: string child_id: string old_day_type: string | null new_day_type: string | null old_is_dubbeldag: boolean | null new_is_dubbeldag: boolean | null old_wish_id: string | null new_wish_id: string | null } function toPlanChange(raw: RawPlanChange): PlanChange { return { kind: raw.kind as PlanChange['kind'], date: raw.date, parentPersonalDataUuid: raw.parent_personal_data_uuid, childId: raw.child_id, oldDayType: raw.old_day_type as PlanChange['oldDayType'], newDayType: raw.new_day_type as PlanChange['newDayType'], oldIsDubbeldag: raw.old_is_dubbeldag, newIsDubbeldag: raw.new_is_dubbeldag, oldWishId: raw.old_wish_id, newWishId: raw.new_wish_id, } } interface RawPlanSyncResponse { proposal: RawPlanProposal apply_result: RawPlanApplyResponse changes: RawPlanChange[] } /** POST /api/plan/sync — the direct-manipulation calendar's one call: * recompute the plan from the workspace's `LeaveWish`es and persist it in * one step (no separate preview), never retyping/overwriting a pinned day, * and reporting exactly what changed via `changes` so the UI can render a * toast ("Sommar 2028 flyttades 2 dagar") instead of silently rewriting the * calendar underneath the user. */ export async function syncPlan(workspaceId: string): Promise { const raw = (await apiFetch('/plan/sync', { method: 'POST', body: JSON.stringify({ workspace_id: workspaceId }), })) as RawPlanSyncResponse return { proposal: toPlanProposal(raw.proposal), applyResult: toPlanApplyResponse(raw.apply_result), changes: raw.changes.map(toPlanChange), } } // --------------------------------------------------------------------------- // Workspace members (used to resolve/select which parent a wish belongs to) // --------------------------------------------------------------------------- export async function listWorkspaceMembers(workspaceId: string): Promise { const raw = (await apiFetch(`/workspace/${workspaceId}/members`)) as RawPersonalData[] return raw.map(toPersonalData) } /** Renames a workspace member (partner or the logged-in user themselves — * the backend deliberately allows self-rename through this route). */ export async function renameWorkspaceParent( workspaceId: string, personalDataUuid: string, displayName: string, ): Promise { const raw = (await apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, { method: 'PATCH', body: JSON.stringify({ display_name: displayName }), })) as RawPersonalData return toPersonalData(raw) } /** Soft-deletes a workspace member. Refused (422) for the caller's own * profile, a member with their own login, or one with dependent data — * see `describePartnerError` in `Settings.tsx` for how those surface. */ export async function removeWorkspaceParent( workspaceId: string, personalDataUuid: string, ): Promise { return apiFetch(`/workspace/${workspaceId}/parents/${personalDataUuid}`, { method: 'DELETE', }) } // --------------------------------------------------------------------------- // Tips // --------------------------------------------------------------------------- export async function getTips(workspaceId: string): Promise { const raw = (await apiFetch(`/tips/${workspaceId}`)) as RawTip[] return raw.map(toTip) } export async function dismissTip(workspaceId: string, tipId: string): Promise { return apiFetch(`/tips/${workspaceId}/dismissals`, { method: 'POST', body: JSON.stringify({ tip_id: tipId }), }) } // --------------------------------------------------------------------------- // Parental Leave Fetch // --------------------------------------------------------------------------- export async function getParentalLeave( personalDataUuid: string, childId: string, ): Promise { // The backend returns `null` (200 OK) rather than 404 when nothing has // been saved yet for this parent/child — a brand-new partner, or self // before any fetch. `toParentalLeaveFetch` assumes a real record, so it // must not be called on that `null`. const raw = (await apiFetch( `/parental-leave/${personalDataUuid}/${childId}`, )) as RawParentalLeaveFetch | null return raw ? toParentalLeaveFetch(raw) : null } // --------------------------------------------------------------------------- // Planned Days // --------------------------------------------------------------------------- export async function getPlannedDays(childId?: string): Promise { const query = childId ? `?child_id=${childId}` : '' const raw = (await apiFetch(`/planned-days${query}`)) as RawPlannedLeaveDay[] return raw.map(toPlannedLeaveDay) } export interface CreatePlannedDayPayload { workspace_id: string child_id: string parent_personal_data_uuid: string date: string day_type: 'sjukpenning' | 'lagsta' | 'vacation' is_dubbeldag?: boolean // True for a day hand-edited through the calendar's day popover — the // planner (POST /api/plan/sync) must not silently retype it. See // `PlannedLeaveDay.isPinned`. is_pinned?: boolean } export async function createPlannedDay(payload: CreatePlannedDayPayload): Promise { const raw = (await apiFetch(`/planned-days`, { method: 'POST', body: JSON.stringify(payload), })) as RawPlannedLeaveDay return toPlannedLeaveDay(raw) } export interface UpdatePlannedDayPayload { day_type?: 'sjukpenning' | 'lagsta' | 'vacation' is_dubbeldag?: boolean // See `CreatePlannedDayPayload.is_pinned`. is_pinned?: boolean } export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise { const raw = (await apiFetch(`/planned-days/${id}`, { method: 'PUT', body: JSON.stringify(payload), })) as RawPlannedLeaveDay return toPlannedLeaveDay(raw) } export async function deletePlannedDay(id: string): Promise { return apiFetch(`/planned-days/${id}`, { method: 'DELETE' }) } // --------------------------------------------------------------------------- // Day overrides (calendar exceptions) // --------------------------------------------------------------------------- export async function upsertDayOverride( req: import('@/types').DayOverrideCreateRequest, ): Promise { const raw = (await apiFetch('/day-overrides', { method: 'POST', body: JSON.stringify({ workspace_id: req.workspaceId, date: req.date, is_working_day: req.isWorkingDay, note: req.note ?? null, }), })) as RawDayOverride return toDayOverride(raw) } export async function listDayOverridesByWorkspace(workspaceId: string): Promise { const raw = (await apiFetch(`/day-overrides/workspace/${workspaceId}`)) as RawDayOverride[] return raw.map(toDayOverride) } export async function deleteDayOverride(id: string): Promise { return apiFetch(`/day-overrides/${id}`, { method: 'DELETE' }) } // --------------------------------------------------------------------------- // Planned-day exclusions — the days the user removed by hand and the planner // must therefore leave empty on every subsequent sync. // --------------------------------------------------------------------------- export async function createPlannedDayExclusion( req: import('@/types').PlannedDayExclusionCreateRequest, ): Promise { const raw = (await apiFetch('/planned-day-exclusions', { method: 'POST', body: JSON.stringify({ workspace_id: req.workspaceId, child_id: req.childId, parent_personal_data_uuid: req.parentPersonalDataUuid, date: req.date, }), })) as RawPlannedDayExclusion return toPlannedDayExclusion(raw) } export async function listPlannedDayExclusionsByWorkspace( workspaceId: string, ): Promise { const raw = (await apiFetch( `/planned-day-exclusions/workspace/${workspaceId}`, )) as RawPlannedDayExclusion[] return raw.map(toPlannedDayExclusion) } export async function deletePlannedDayExclusion(id: string): Promise { return apiFetch(`/planned-day-exclusions/${id}`, { method: 'DELETE' }) } // --------------------------------------------------------------------------- // Calendar (röda dagar / klämdagar) — GET /api/calendar/days, backs §3.4 // (Kalender shading), §3.6 (click-to-override) and §3.8 (Settings red-days // list) from one endpoint. See ARCHITECTURE.md §13.3. // --------------------------------------------------------------------------- interface RawCalendarDay { date: string name: string | null day_type: 'red_day' | 'klamdag' | 'weekend' | 'working_day' is_override: boolean override_is_working_day: boolean | null is_free: boolean } interface RawCalendarWindow { start_year: number end_year: number min_year: number | null max_year: number | null days: RawCalendarDay[] } function toCalendarDayEntry(raw: RawCalendarDay): CalendarDayEntry { return { date: raw.date, name: raw.name, dayType: raw.day_type, isOverride: raw.is_override, overrideIsWorkingDay: raw.override_is_working_day, isFree: raw.is_free, } } function toCalendarWindow(raw: RawCalendarWindow): CalendarWindow { return { startYear: raw.start_year, endYear: raw.end_year, minYear: raw.min_year, maxYear: raw.max_year, days: raw.days.map(toCalendarDayEntry), } } /** Fetches `years` years (1-4, capped server-side) of calendar metadata * starting at `startYear`, merged with the workspace's day overrides. */ export async function getCalendarDays( workspaceId: string, startYear: number, years: number = 4, ): Promise { const raw = (await apiFetch( `/calendar/days?workspace_id=${encodeURIComponent(workspaceId)}&start_year=${startYear}&years=${years}`, )) as RawCalendarWindow return toCalendarWindow(raw) } // --------------------------------------------------------------------------- // FK Scraper (dev / authenticated users only — requires scraper_enabled=true) // --------------------------------------------------------------------------- interface ScraperStartRequest { workspace_id?: string personal_data_uuid?: string child_id?: string } interface ScraperStartResponse { session_id: string status: string workspace_id: string personal_data_uuid: string child_id: string } export async function startScraperSession( payload: ScraperStartRequest, ): Promise { return apiFetch('/fetch-data/start', { method: 'POST', body: JSON.stringify(payload) }) } export async function pollScraperSession(sessionId: string): Promise { return apiFetch(`/fetch-data/poll?session_id=${encodeURIComponent(sessionId)}`) } export async function cancelScraperSession(sessionId: string): Promise { return apiFetch('/fetch-data/cancel', { method: 'POST', body: JSON.stringify({ session_id: sessionId }), }) } // --------------------------------------------------------------------------- // Preschool planning days ("studiedagar") — /api/preschool-planning-days // --------------------------------------------------------------------------- interface RawPreschoolPlanningDays { id: string | null child_id: string year: number planned_days: number taken_days: number | null is_default: boolean created_at: string | null updated_at: string | null } function toPreschoolPlanningDays(raw: RawPreschoolPlanningDays): PreschoolPlanningDays { return { id: raw.id, childId: raw.child_id, year: raw.year, plannedDays: raw.planned_days, takenDays: raw.taken_days, isDefault: raw.is_default, createdAt: raw.created_at, updatedAt: raw.updated_at, } } export async function upsertPreschoolPlanningDaysPlanned( childId: string, year: number, req: PreschoolPlanningDaysPlannedRequest, ): Promise { const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/planned`, { method: 'PUT', body: JSON.stringify({ planned_days: req.plannedDays }), })) as RawPreschoolPlanningDays return toPreschoolPlanningDays(raw) } export async function recordPreschoolPlanningDaysTaken( childId: string, year: number, req: PreschoolPlanningDaysTakenRequest, ): Promise { const raw = (await apiFetch(`/preschool-planning-days/${childId}/${year}/taken`, { method: 'PUT', body: JSON.stringify({ taken_days: req.takenDays }), })) as RawPreschoolPlanningDays return toPreschoolPlanningDays(raw) } export async function getPreschoolPlanningDaysYear( childId: string, year: number, ): Promise { const raw = (await apiFetch( `/preschool-planning-days/${childId}/${year}`, )) as RawPreschoolPlanningDays return toPreschoolPlanningDays(raw) } export async function listPreschoolPlanningDays(childId: string): Promise { const raw = (await apiFetch(`/preschool-planning-days/${childId}`)) as RawPreschoolPlanningDays[] return raw.map(toPreschoolPlanningDays) } // --------------------------------------------------------------------------- // Invitations — /api/workspace/{id}/invitations, /api/invitations/* // --------------------------------------------------------------------------- interface RawInvitation { id: string workspace_id: string status: 'pending' | 'accepted' | 'revoked' | 'expired' expires_at: string created_at: string } function toInvitation(raw: RawInvitation): Invitation { return { id: raw.id, workspaceId: raw.workspace_id, status: raw.status, expiresAt: raw.expires_at, createdAt: raw.created_at, } } /** Creates a one-time invite for `workspaceId`; the raw `token` is returned * once and never stored server-side — share it out-of-band (the backend * only ever persists its hash). */ export async function createInvitation(workspaceId: string): Promise { return apiFetch(`/workspace/${workspaceId}/invitations`, { method: 'POST' }) } /** Lists this workspace's invitations. Never includes `token_hash` — the * backend omits it from the wire response entirely. */ export async function listInvitations(workspaceId: string): Promise { const raw = (await apiFetch(`/workspace/${workspaceId}/invitations`)) as RawInvitation[] return raw.map(toInvitation) } export async function redeemInvitation(token: string): Promise { const raw = (await apiFetch('/invitations/redeem', { method: 'POST', body: JSON.stringify({ token }), })) as RawInvitation return toInvitation(raw) } /** The caller's own workspace is inferred server-side from their session — * no workspace id is sent. */ export async function revokeInvitation(invitationId: string): Promise { return apiFetch(`/invitations/${invitationId}`, { method: 'DELETE' }) }