88197060de
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
107 lines
4.0 KiB
TypeScript
107 lines
4.0 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import * as api from '@/services/api'
|
|
import { useLatestRequest } from './useLatestRequest'
|
|
import type {
|
|
LeaveWish,
|
|
LeaveWishCreateRequest,
|
|
LeaveWishScope,
|
|
LeaveWishUpdateRequest,
|
|
} from '@/types'
|
|
|
|
interface UseLeaveWishesResult {
|
|
data: LeaveWish[]
|
|
/** 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`; a consumer that renders a blocking
|
|
* spinner should NOT key off this — it exists so a mutation's list refetch
|
|
* doesn't unmount whatever is currently showing `data`. */
|
|
isRefreshing: boolean
|
|
error: string | null
|
|
refresh: () => Promise<void>
|
|
add: (req: LeaveWishCreateRequest) => Promise<LeaveWish>
|
|
/** `scope` decides how far the edit reaches on a repeating wish: the
|
|
* clicked year only (default) or every year sharing its `seriesId`. */
|
|
update: (id: string, req: LeaveWishUpdateRequest, scope?: LeaveWishScope) => Promise<LeaveWish>
|
|
/** `scope: 'series'` removes every year of a repeating wish. */
|
|
remove: (id: string, scope?: LeaveWishScope) => Promise<void>
|
|
}
|
|
|
|
/** CRUD over a workspace's recurring leave wishes (`/api/leave-wishes`).
|
|
*
|
|
* `fetch` is called both on initial mount/workspace-change and after every
|
|
* mutation (to keep `data` in sync with the server). Those two cases need
|
|
* different loading semantics: the initial load should block the page, but
|
|
* a post-mutation refresh must not — it happens while the user is mid-edit
|
|
* of a form built from `data`, and flipping `isLoading` would unmount that
|
|
* form. `background: true` routes the fetch through `isRefreshing` instead
|
|
* of `isLoading` so callers can tell the two apart.
|
|
*/
|
|
export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult {
|
|
const [data, setData] = useState<LeaveWish[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const { start, isCurrent } = useLatestRequest()
|
|
|
|
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.getLeaveWishes(workspaceId)
|
|
if (!isCurrent(requestId)) return
|
|
setData(result)
|
|
} catch (err: unknown) {
|
|
if (!isCurrent(requestId)) return
|
|
setError(err instanceof Error ? err.message : 'Failed to load leave wishes')
|
|
} 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 add = useCallback(
|
|
async (req: LeaveWishCreateRequest) => {
|
|
const created = await api.createLeaveWish(req)
|
|
await fetch({ background: true })
|
|
return created
|
|
},
|
|
[fetch],
|
|
)
|
|
|
|
const update = useCallback(
|
|
async (id: string, req: LeaveWishUpdateRequest, scope: LeaveWishScope = 'occurrence') => {
|
|
const updated = await api.updateLeaveWish(id, req, scope)
|
|
await fetch({ background: true })
|
|
return updated
|
|
},
|
|
[fetch],
|
|
)
|
|
|
|
const remove = useCallback(
|
|
async (id: string, scope: LeaveWishScope = 'occurrence') => {
|
|
await api.deleteLeaveWish(id, scope)
|
|
await fetch({ background: true })
|
|
},
|
|
[fetch],
|
|
)
|
|
|
|
return { data, isLoading, isRefreshing, error, refresh: fetch, add, update, remove }
|
|
}
|