feat(platform): harden access scoping and delivery baseline

This commit is contained in:
2026-03-30 00:27:31 +02:00
parent 00b936fa1f
commit 819345acfa
109 changed files with 26142 additions and 8081 deletions
@@ -8,6 +8,7 @@ import {
type DemandRequirement,
} from "@capakraken/shared";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useSession } from "next-auth/react";
import { useSearchParams } from "next/navigation";
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -217,7 +218,13 @@ export function TimelineProvider({
isDragging,
children,
}: TimelineProviderProps) {
const { data: session, status: sessionStatus } = useSession();
const searchParams = useSearchParams();
const role = sessionStatus === "authenticated"
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
: null;
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
const isRoleLoading = sessionStatus !== "authenticated";
const today = useMemo(() => {
const d = new Date();
@@ -283,19 +290,25 @@ export function TimelineProvider({
// ─── Data queries ──────────────────────────────────────────────────────────
const mountedRef = useRef(false);
const timelineQueryInput = {
startDate: viewStart,
endDate: viewEnd,
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
};
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
timelineQueryInput,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
{
enabled: !isRoleLoading && !isSelfServiceTimeline,
placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as {
data: TimelineEntriesView | undefined;
@@ -303,6 +316,23 @@ export function TimelineProvider({
refetch: () => Promise<unknown>;
};
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
timelineQueryInput,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{
enabled: !isRoleLoading && isSelfServiceTimeline,
placeholderData: (prev: any) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) as {
data: TimelineEntriesView | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
const assignments = entriesView?.assignments ?? [];
@@ -316,24 +346,33 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
timelineQueryInput,
{
enabled: !isRoleLoading && !isSelfServiceTimeline,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
);
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
timelineQueryInput,
{
enabled: !isRoleLoading && isSelfServiceTimeline,
placeholderData: (prev) => prev,
refetchOnWindowFocus: false,
staleTime: 90_000,
},
);
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
const {
data: holidayOverlayEntries = [],
refetch: refetchHolidayOverlays,
} = trpc.timeline.getHolidayOverlays.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
} = activeHolidayOverlayQuery;
useEffect(() => {
if (mountedRef.current) return;
if (isRoleLoading) return;
mountedRef.current = true;
// Harden client-side route transitions: the timeline must actively refresh
@@ -341,7 +380,7 @@ export function TimelineProvider({
void refetchEntriesView();
void refetchVacations();
void refetchHolidayOverlays();
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
}, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
@@ -378,9 +417,9 @@ export function TimelineProvider({
}, [holidayOverlayEntries, vacationEntries]);
// When EID filter is active, explicitly fetch those resources.
const { data: eidFilterData } = trpc.resource.list.useQuery(
const { data: eidFilterData } = trpc.resource.directory.useQuery(
{ eids: filters.eids, limit: 100 },
{ enabled: filters.eids.length > 0, staleTime: 30_000 },
{ enabled: !isSelfServiceTimeline && filters.eids.length > 0, staleTime: 30_000 },
);
// ─── Filtered entries ──────────────────────────────────────────────────────
@@ -633,7 +672,7 @@ export function TimelineProvider({
]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Derived counts ───────────────────────────────────────────────────────
const isInitialLoading = isLoading && !entriesView;
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
const totalAllocCount = entriesView?.allocations.length ?? 0;
const activeFilterCount =
filters.clientIds.length +