import type { AuthCredentials, AuthResponse, AuthSession, EntityId, Household, HouseholdHistoryEntry, HouseholdInvitePayload, HouseholdPayload, InventoryItem, InventoryItemPayload, Location, LocationHistoryEntry, LocationPayload, MealPlanner, MealPlannerPayload, ProfileUpdatePayload, RegisterPayload, ShoppingList, ShoppingListPayload, User, } from '../types/models' const SESSION_STORAGE_KEY = 'pantry-management-session' const SESSION_CHANGE_EVENT = 'pantry-management-session-change' const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '') let refreshPromise: Promise | null = null export class ApiError extends Error { status: number data: unknown constructor(message: string, status = 0, data: unknown = null) { super(message) this.name = 'ApiError' this.status = status this.data = data } } interface RequestOptions { method?: string body?: unknown headers?: Record skipAuth?: boolean retryOnAuthFailure?: boolean } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } function buildUrl(path: string): string { return API_BASE_URL ? `${API_BASE_URL}${path}` : path } function parseStoredSession(rawValue: string | null): AuthSession | null { if (!rawValue) return null try { return JSON.parse(rawValue) as AuthSession } catch { return null } } function dispatchSessionChange(session: AuthSession | null): void { if (typeof window === 'undefined') return window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, { detail: session, })) } export function getStoredSession(): AuthSession | null { if (typeof window === 'undefined') return null return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) } export function saveSession(session: AuthSession): AuthSession { if (typeof window === 'undefined') return session window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) dispatchSessionChange(session) return session } export function clearSession(): void { if (typeof window === 'undefined') return window.localStorage.removeItem(SESSION_STORAGE_KEY) dispatchSessionChange(null) } export function subscribeToSessionChanges(listener: (session: AuthSession | null) => void): () => void { if (typeof window === 'undefined') return () => {} function handleCustomEvent(event: Event) { listener((event as CustomEvent).detail ?? null) } function handleStorageEvent(event: StorageEvent) { if (event.key !== SESSION_STORAGE_KEY) return listener(parseStoredSession(event.newValue)) } window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) window.addEventListener('storage', handleStorageEvent) return () => { window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) window.removeEventListener('storage', handleStorageEvent) } } async function readResponse(response: Response): Promise { const text = await response.text() if (!text) return null const contentType = response.headers.get('content-type') ?? '' if (contentType.includes('json')) { try { return JSON.parse(text) } catch { return text } } try { return JSON.parse(text) } catch { return text } } function extractErrorMessage(data: unknown, fallbackMessage: string): string { if (!data) return fallbackMessage if (typeof data === 'string' && data.trim()) { return data } if (!isRecord(data)) { return fallbackMessage } if (typeof data.message === 'string' && data.message.trim()) { return data.message } if (typeof data.Message === 'string' && data.Message.trim()) { return data.Message } if (typeof data.error === 'string' && data.error.trim()) { return data.error } if (data.errors && typeof data.errors === 'object') { const messages = Object.values(data.errors) .flatMap(value => Array.isArray(value) ? value : [value]) .filter(Boolean) if (messages.length > 0) { return messages.map(String).join(' ') } } if (typeof data.title === 'string' && data.title.trim()) { return data.title } return fallbackMessage } async function performRefresh(session: AuthSession | null): Promise { if (!session?.refreshToken) { clearSession() throw new ApiError('Your session expired. Sign in again.', 401) } let response try { response = await fetch(buildUrl('/api/auth/refresh-token'), { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ accessToken: session.accessToken, refreshToken: session.refreshToken, }), }) } catch (error) { throw new ApiError( 'Unable to refresh your session. Make sure the API is running and reachable.', 0, error, ) } const data = await readResponse(response) if (!response.ok) { clearSession() throw new ApiError( extractErrorMessage(data, 'Your session expired. Sign in again.'), response.status, data, ) } const refreshData = data as Partial return saveSession({ accessToken: refreshData.accessToken ?? '', refreshToken: refreshData.refreshToken ?? '', user: refreshData.user ?? session.user ?? null, }) } async function refreshSession(): Promise { if (!refreshPromise) { refreshPromise = performRefresh(getStoredSession()) .finally(() => { refreshPromise = null }) } return refreshPromise } async function requestJson(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, headers = {}, skipAuth = false, retryOnAuthFailure = true, } = options const session = getStoredSession() const requestHeaders: Record = { Accept: 'application/json', ...headers, } if (body !== undefined) { requestHeaders['Content-Type'] = 'application/json' } if (!skipAuth && session?.accessToken) { requestHeaders.Authorization = `Bearer ${session.accessToken}` } let response try { response = await fetch(buildUrl(path), { method, headers: requestHeaders, body: body === undefined ? undefined : JSON.stringify(body), }) } catch (error) { throw new ApiError( 'Unable to reach the API. Make sure the backend is running and the certificate is trusted.', 0, error, ) } const data = await readResponse(response) if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) { await refreshSession() return requestJson(path, { ...options, retryOnAuthFailure: false }) } if (!response.ok) { throw new ApiError( extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`), response.status, data, ) } return data as T } export const authApi = { async register(payload: RegisterPayload): Promise { return requestJson('/api/auth/register', { method: 'POST', body: payload, skipAuth: true, }) }, async login(payload: AuthCredentials): Promise { const data = await requestJson('/api/auth/login', { method: 'POST', body: payload, skipAuth: true, }) saveSession({ accessToken: data.accessToken, refreshToken: data.refreshToken, user: data.user ?? null, }) return data }, async logout(): Promise { try { await requestJson('/api/auth/logout', { method: 'POST', }) } finally { clearSession() } }, } export const profileApi = { getProfile(): Promise { return requestJson('/api/profile') }, getProtectedData(): Promise { return requestJson('/api/profile/data') }, updateProfile(payload: ProfileUpdatePayload): Promise { return requestJson('/api/profile', { method: 'PUT', body: payload, }) }, } export const locationsApi = { getLocations(): Promise { return requestJson('/api/locations') }, getLocationHistory(id: EntityId): Promise { return requestJson(`/api/locations/${id}/history`) }, createLocation(payload: LocationPayload): Promise { return requestJson('/api/locations', { method: 'POST', body: payload, }) }, updateLocation(id: EntityId, payload: LocationPayload): Promise { return requestJson(`/api/locations/${id}`, { method: 'PUT', body: payload, }) }, deleteLocation(id: EntityId): Promise { return requestJson(`/api/locations/${id}`, { method: 'DELETE', }) }, } export const inventoryApi = { getInventoryItems(): Promise { return requestJson('/api/inventoryitems') }, getInventoryItem(id: EntityId): Promise { return requestJson(`/api/inventoryitems/${id}`) }, createInventoryItem(payload: InventoryItemPayload): Promise { return requestJson('/api/inventoryitems', { method: 'POST', body: payload, }) }, updateInventoryItem(id: EntityId, payload: InventoryItemPayload): Promise { return requestJson(`/api/inventoryitems/${id}`, { method: 'PUT', body: payload, }) }, deleteInventoryItem(id: EntityId): Promise { return requestJson(`/api/inventoryitems/${id}`, { method: 'DELETE', }) }, } export const searchApi = { searchLocations(query: string): Promise { return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`) }, searchItems(query: string): Promise { return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`) }, } export const householdsApi = { getHouseholds(): Promise { return requestJson('/api/households') }, getHouseholdHistory(id: EntityId): Promise { return requestJson(`/api/households/${id}/history`) }, createHousehold(payload: HouseholdPayload): Promise { return requestJson('/api/households', { method: 'POST', body: payload, }) }, updateHousehold(id: EntityId, payload: HouseholdPayload): Promise { return requestJson(`/api/households/${id}`, { method: 'PUT', body: payload, }) }, inviteHouseholdMember(id: EntityId, payload: HouseholdInvitePayload): Promise { return requestJson(`/api/households/${id}/invite`, { method: 'POST', body: payload, }) }, leaveHousehold(id: EntityId): Promise { return requestJson(`/api/households/${id}/leave`, { method: 'DELETE', }) }, } export const usersApi = { getUsers(): Promise { return requestJson('/api/users') }, getUser(id: EntityId): Promise { return requestJson(`/api/users/${id}`) }, updateUser(id: EntityId, payload: Partial & { roles?: string[] }): Promise { return requestJson(`/api/users/${id}`, { method: 'PUT', body: payload, }) }, } export const shoppingListsApi = { getShoppingLists(): Promise { return requestJson('/api/shoppinglists') }, getShoppingList(id: EntityId): Promise { return requestJson(`/api/shoppinglists/${id}`) }, createShoppingList(payload: ShoppingListPayload): Promise { return requestJson('/api/shoppinglists', { method: 'POST', body: payload, }) }, updateShoppingList(id: EntityId, payload: ShoppingListPayload): Promise { return requestJson(`/api/shoppinglists/${id}`, { method: 'PUT', body: payload, }) }, deleteShoppingList(id: EntityId): Promise { return requestJson(`/api/shoppinglists/${id}`, { method: 'DELETE', }) }, } export const mealPlannersApi = { getMealPlanners(): Promise { return requestJson('/api/mealplanners') }, getMealPlanner(id: EntityId): Promise { return requestJson(`/api/mealplanners/${id}`) }, createMealPlanner(payload: MealPlannerPayload): Promise { return requestJson('/api/mealplanners', { method: 'POST', body: payload, }) }, updateMealPlanner(id: EntityId, payload: MealPlannerPayload): Promise { return requestJson(`/api/mealplanners/${id}`, { method: 'PUT', body: payload, }) }, deleteMealPlanner(id: EntityId): Promise { return requestJson(`/api/mealplanners/${id}`, { method: 'DELETE', }) }, }