Files
chore/frontend/src/common/api.ts
Ryan Kegel 5392e5af70
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
Add routine management features for child and parent views
- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion.
- Created notification tests for parent view to verify routine completion notifications for children.
- Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions.
- Added RoutineApproveDialog component for approving or rejecting completed routines.
- Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering.
- Enhanced RoutineEditView with proper handling of task addition and form submission.

Co-authored-by: Copilot <copilot@github.com>
2026-05-17 23:47:12 -04:00

467 lines
14 KiB
TypeScript

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<boolean> | 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<boolean> {
// 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<typeof fetch>) => {
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<Response> {
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<Response> {
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<Response> {
return setChildOverride(childId, routineId, 'routine', customValue)
}
/**
* Get all overrides for a specific child.
*/
export async function getChildOverrides(childId: string): Promise<Response> {
return fetch(`/api/child/${childId}/overrides`)
}
/**
* Delete an override (reset to default).
*/
export async function deleteChildOverride(childId: string, entityId: string): Promise<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
return fetch('/api/pending-confirmations')
}
/**
* Child confirms they completed a routine.
*/
export async function confirmRoutine(childId: string, routineId: string): Promise<Response> {
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<Response> {
return fetch(`/api/child/${childId}/cancel-routine-confirmation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: routineId }),
})
}
/**
* Parent approves a pending routine confirmation (awards points).
*/
export async function approveRoutine(childId: string, confirmationId: string): Promise<Response> {
return fetch(`/api/child/${childId}/approve-routine/${confirmationId}`, {
method: 'POST',
})
}
/**
* Parent rejects a pending routine confirmation (no points, resets to available).
*/
export async function rejectRoutine(childId: string, confirmationId: string): Promise<Response> {
return fetch(`/api/child/${childId}/reject-routine/${confirmationId}`, {
method: 'POST',
})
}
/**
* Parent resets an approved routine (routine can be confirmed again).
*/
export async function resetRoutine(childId: string, confirmationId: string): Promise<Response> {
return fetch(`/api/child/${childId}/reset-routine/${confirmationId}`, {
method: 'POST',
})
}
/**
* Parent triggers a routine directly (awards points immediately).
*/
export async function triggerRoutineAsParent(
childId: string,
routineId: string,
): Promise<Response> {
return fetch(`/api/child/${childId}/trigger-routine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: routineId }),
})
}
/**
* Assign a single routine to a child.
*/
export async function assignRoutine(childId: string, routineId: string): Promise<Response> {
return fetch(`/api/child/${childId}/assign-routine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: routineId }),
})
}
/**
* Remove a single routine assignment from a child.
*/
export async function removeRoutine(childId: string, routineId: string): Promise<Response> {
return fetch(`/api/child/${childId}/remove-routine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: routineId }),
})
}
/**
* Replace all routine assignments for a child.
*/
export async function setChildRoutines(childId: string, routineIds: string[]): Promise<Response> {
return fetch(`/api/child/${childId}/set-routines`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_ids: routineIds }),
})
}