All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s
- 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>
467 lines
14 KiB
TypeScript
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 }),
|
|
})
|
|
}
|