Files
F-r-ldraledigplannerare/frontend/src/hooks/useDayOverrides.ts
T
jacobh2 88197060de Close the residual gaps the 2026-09-18 audit left open
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.

Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
  child_id as an unchecked query param, and get_overview called
  get_days_left_at_age_4 without workspace_id, so the ownership guard
  never ran. Any member could read another workspace's child projection.
  Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
  failure's raw exception text and the FK maintenance banner's scraped
  text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
  lowercasing existing rows (aborts naming colliding rows rather than
  failing opaquely on the UNIQUE constraint).

Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
  hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
  off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
  consumers. setBusy(true) was unconditional while the clear was guarded
  by isCurrent, so a superseded call stranded its flag forever — under
  StrictMode this hung every in-app navigation behind the PageSkeleton.
  isCurrent decides which response's data wins, never whether a call
  releases the flag it raised.

Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
  writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
  GET /workspace/:id from every cold load.

CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).

Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.

Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
2026-09-21 06:16:23 +02:00

84 lines
3.0 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import { useWorkspace } from '@/context'
import type { DayOverride, DayOverrideCreateRequest } from '@/types'
interface UseDayOverridesResult {
data: DayOverride[]
/** True only while the initial (or workspace-switch) load is in flight —
* safe to gate a full-page blocking spinner on. */
isLoading: boolean
/** True while a post-mutation background refresh is in flight. Never true
* at the same time as `isLoading` — see `useLeaveWishes.ts`'s doc comment
* for why a mutation's refetch must not flip the initial-load flag. */
isRefreshing: boolean
error: string | null
upsert: (req: DayOverrideCreateRequest) => Promise<void>
remove: (id: string) => Promise<void>
}
export function useDayOverrides(workspaceId: string): UseDayOverridesResult {
const [data, setData] = useState<DayOverride[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest()
const { bumpPlanVersion } = useWorkspace()
const fetch = useCallback(
async (options?: { background?: boolean }) => {
const requestId = start()
if (!workspaceId) return
const background = options?.background ?? false
const setBusy = background ? setIsRefreshing : setIsLoading
setBusy(true)
setError(null)
try {
const result = await api.listDayOverridesByWorkspace(workspaceId)
if (!isCurrent(requestId)) return
setData(result)
} catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load day overrides')
} finally {
// Unguarded: `isCurrent` decides which response's DATA wins, not
// whether a superseded call's own busy flag gets cleared — a stale
// call must still release the flag IT raised, or it can strand
// that flag true forever (see usePlannedDays.ts for the hang this
// exact guard caused there).
setBusy(false)
}
},
[workspaceId, start, isCurrent],
)
useEffect(() => {
void fetch()
}, [fetch])
const upsert = useCallback(
async (req: DayOverrideCreateRequest) => {
await api.upsertDayOverride(req)
await fetch({ background: true })
// An override changes which days count as free/working, which feeds
// the at-risk-days analytics — bump the shared invalidation counter
// so other hook instances (e.g. useAnalytics) refetch. See
// `WorkspaceContext.planVersion`.
bumpPlanVersion()
},
[fetch, bumpPlanVersion],
)
const remove = useCallback(
async (id: string) => {
await api.deleteDayOverride(id)
await fetch({ background: true })
bumpPlanVersion()
},
[fetch, bumpPlanVersion],
)
return { data, isLoading, isRefreshing, error, upsert, remove }
}