import { logoutUser } from '@/stores/auth' const hasLocalStorage = typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' const CROSS_TAB_REFRESH_KEY = 'lastRefreshAt' const CROSS_TAB_REFRESH_MIN_INTERVAL = 8_000 // 8s — prevents concurrent multi-tab refresh let unauthorizedInterceptorInstalled = false let unauthorizedRedirectHandler: (() => void) | null = null let unauthorizedHandlingInProgress = false // Mutex for refresh token requests — prevents concurrent refresh calls let refreshPromise: Promise | null = null export function setUnauthorizedRedirectHandlerForTests(handler: (() => void) | null): void { unauthorizedRedirectHandler = handler } /** Reset interceptor state (for tests). */ export function resetInterceptorStateForTests(): void { unauthorizedHandlingInProgress = false refreshPromise = null if (hasLocalStorage && typeof localStorage.removeItem === 'function') { localStorage.removeItem(CROSS_TAB_REFRESH_KEY) } } function handleUnauthorizedResponse(): void { if (unauthorizedHandlingInProgress) return unauthorizedHandlingInProgress = true logoutUser() if (typeof window === 'undefined') return if (window.location.pathname.startsWith('/auth')) return if (window.location.pathname === '/') return if (unauthorizedRedirectHandler) { unauthorizedRedirectHandler() return } window.location.assign('/') } /** * Attempt to refresh the access token by calling the refresh endpoint. * Returns true if refresh succeeded, false otherwise. * Uses a mutex so concurrent 401s only trigger one refresh call. * Also uses a localStorage timestamp to coordinate across tabs, preventing * concurrent refreshes that would trigger false-positive theft detection. */ async function attemptTokenRefresh(originalFetch: typeof fetch): Promise { // Cross-tab coordination: if another tab refreshed recently, the new cookie // is already set. Skip the refresh call and let the original request retry. if (hasLocalStorage && typeof localStorage.getItem === 'function') { const lastRefresh = localStorage.getItem(CROSS_TAB_REFRESH_KEY) if (lastRefresh && Date.now() - Number(lastRefresh) < CROSS_TAB_REFRESH_MIN_INTERVAL) { return true } } if (refreshPromise) return refreshPromise refreshPromise = (async () => { try { const res = await originalFetch('/api/auth/refresh', { method: 'POST' }) if (res.ok && hasLocalStorage && typeof localStorage.setItem === 'function') { localStorage.setItem(CROSS_TAB_REFRESH_KEY, String(Date.now())) } return res.ok } catch { return false } finally { refreshPromise = null } })() return refreshPromise } /** URLs that should NOT trigger a refresh attempt on 401. */ function isAuthUrl(url: string): boolean { return url.includes('/api/auth/refresh') || url.includes('/api/auth/login') } export function installUnauthorizedFetchInterceptor(): void { if (unauthorizedInterceptorInstalled || typeof window === 'undefined') return unauthorizedInterceptorInstalled = true const originalFetch = globalThis.fetch.bind(globalThis) const wrappedFetch = (async (...args: Parameters) => { const response = await originalFetch(...args) if (response.status === 401) { // Determine the request URL const requestUrl = typeof args[0] === 'string' ? args[0] : (args[0] as Request).url // Don't attempt refresh for auth endpoints themselves if (isAuthUrl(requestUrl)) { handleUnauthorizedResponse() return response } // Attempt silent refresh const refreshed = await attemptTokenRefresh(originalFetch) if (refreshed) { // Retry the original request with the new access token cookie return originalFetch(...args) } // Refresh failed — log out handleUnauthorizedResponse() } return response }) as typeof fetch window.fetch = wrappedFetch as typeof window.fetch globalThis.fetch = wrappedFetch as typeof globalThis.fetch } export async function parseErrorResponse(res: Response): Promise<{ msg: string; code?: string }> { try { const data = await res.json() return { msg: data.error || data.message || 'Error', code: data.code } } catch { const text = await res.text() return { msg: text || 'Error' } } } export function isEmailValid(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) } export function isPasswordStrong(password: string): boolean { return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password) } /** * Fetch tracking events for a child with optional filters. */ export async function getTrackingEventsForChild(params: { childId: string entityType?: 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' action?: | 'activated' | 'requested' | 'redeemed' | 'cancelled' | 'confirmed' | 'approved' | 'rejected' | 'reset' limit?: number offset?: number }): Promise { const query = new URLSearchParams() query.set('child_id', params.childId) if (params.entityType) query.set('entity_type', params.entityType) if (params.action) query.set('action', params.action) if (params.limit) query.set('limit', params.limit.toString()) if (params.offset) query.set('offset', params.offset.toString()) return fetch(`/api/admin/tracking?${query.toString()}`) } /** * Set or update a custom value for a task/reward for a specific child. */ export async function setChildOverride( childId: string, entityId: string, entityType: 'task' | 'reward' | 'routine', customValue: number, ): Promise { return fetch(`/api/child/${childId}/override`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entity_id: entityId, entity_type: entityType, custom_value: customValue, }), }) } export async function setChildRoutineOverride( childId: string, routineId: string, customValue: number, ): Promise { return setChildOverride(childId, routineId, 'routine', customValue) } /** * Get all overrides for a specific child. */ export async function getChildOverrides(childId: string): Promise { return fetch(`/api/child/${childId}/overrides`) } /** * Delete an override (reset to default). */ export async function deleteChildOverride(childId: string, entityId: string): Promise { return fetch(`/api/child/${childId}/override/${entityId}`, { method: 'DELETE', }) } // ── Chore Schedule API ────────────────────────────────────────────────────── /** * Get the schedule for a specific child + task pair. */ export async function getChoreSchedule(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/task/${taskId}/schedule`) } /** * Create or replace the schedule for a specific child + task pair. */ export async function setChoreSchedule( childId: string, taskId: string, schedule: object, ): Promise { return fetch(`/api/child/${childId}/task/${taskId}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(schedule), }) } /** * Delete the schedule for a specific child + task pair. */ export async function deleteChoreSchedule(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/task/${taskId}/schedule`, { method: 'DELETE', }) } /** * Get the schedule for a specific child + routine pair. */ export async function getRoutineSchedule(childId: string, routineId: string): Promise { return fetch(`/api/child/${childId}/routine/${routineId}/schedule`) } /** * Create or replace the schedule for a specific child + routine pair. */ export async function setRoutineSchedule( childId: string, routineId: string, schedule: object, ): Promise { return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(schedule), }) } /** * Delete the schedule for a specific child + routine pair. */ export async function deleteRoutineSchedule(childId: string, routineId: string): Promise { return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, { method: 'DELETE', }) } /** * Extend a timed-out chore for the remainder of today only. * `localDate` is the client's local ISO date (e.g. '2026-02-22'). */ export async function extendChoreTime( childId: string, taskId: string, localDate: string, ): Promise { return fetch(`/api/child/${childId}/task/${taskId}/extend`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: localDate }), }) } /** * Extend a timed-out routine for the remainder of today only. * `localDate` is the client's local ISO date (e.g. '2026-02-22'). */ export async function extendRoutineTime( childId: string, routineId: string, localDate: string, ): Promise { return fetch(`/api/child/${childId}/routine/${routineId}/extend`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: localDate }), }) } // ── Chore Confirmation API ────────────────────────────────────────────────── /** * Child confirms they completed a chore. */ export async function confirmChore(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/confirm-chore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }) } /** * Child cancels a pending chore confirmation. */ export async function cancelConfirmChore(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/cancel-confirm-chore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }) } /** * Parent approves a pending chore confirmation (awards points). */ export async function approveChore(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/approve-chore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }) } /** * Parent rejects a pending chore confirmation (no points, resets to available). */ export async function rejectChore(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/reject-chore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }) } /** * Parent resets a completed chore (points kept, chore can be confirmed again). */ export async function resetChore(childId: string, taskId: string): Promise { return fetch(`/api/child/${childId}/reset-chore`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task_id: taskId }), }) } /** * Fetch all pending confirmations (both chores and rewards) for the current user. */ export async function fetchPendingConfirmations(): Promise { return fetch('/api/pending-confirmations') } /** * Child confirms they completed a routine. */ export async function confirmRoutine(childId: string, routineId: string): Promise { return fetch(`/api/child/${childId}/confirm-routine`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routine_id: routineId }), }) } /** * Child cancels a pending routine confirmation. */ export async function cancelRoutineConfirmation( childId: string, routineId: string, ): Promise { return fetch(`/api/child/${childId}/cancel-routine-confirmation`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ routine_id: routineId }), }) }