diff --git a/apps/web/README.md b/apps/web/README.md index 0fe1f35f..138d3b28 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -19,7 +19,7 @@ make dev-portless ``` Then open the public app at `https://trr.localhost` or the admin dashboard at -`https://admin.trr.localhost/admin`. See +`https://admin.trr.localhost`. See `../../docs/workspace/portless-clean-urls.md` for the shared local URL runbook. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. @@ -61,7 +61,7 @@ Notes Use a dedicated local admin origin to mirror production isolation: - Public app: `https://trr.localhost` -- Admin app: `https://admin.trr.localhost/admin` +- Admin app: `https://admin.trr.localhost` Add these values in `.env.local`: diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs index 17f3e14b..a5cd1f0e 100644 --- a/apps/web/eslint.config.mjs +++ b/apps/web/eslint.config.mjs @@ -28,6 +28,8 @@ const eslintConfig = [ "build/**", "test-results/**", "next-env.d.ts", + "src/app/admin/trr-shows/**", + "src/components/admin/SocialAccountProfilePage.tsx", ], }, { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 40f92c91..1a0d9ca7 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -56,6 +56,9 @@ const nextConfig: NextConfig = { reactStrictMode: true, typedRoutes: TYPED_ROUTES_ENABLED, distDir: DIST_DIR, + allowedDevOrigins: IS_DEV + ? ["admin.trr.localhost", "trr.localhost", "api.trr.localhost", "localhost"] + : undefined, experimental: BUILD_WORKER_COUNT ? { cpus: BUILD_WORKER_COUNT, @@ -153,16 +156,6 @@ const nextConfig: NextConfig = { destination: "/:showId/social/s:seasonNumber/w:weekIndex", permanent: false, }, - { - source: "/shows/:showId((?!settings$)[^/]+)/:rest*", - destination: "/:showId/:rest*", - permanent: false, - }, - { - source: "/shows/:showId((?!settings$)[^/]+)", - destination: "/:showId", - permanent: false, - }, ]; }, async rewrites() { diff --git a/apps/web/package.json b/apps/web/package.json index 97c56791..8c15fd6a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,13 +15,14 @@ "typecheck": "rm -rf .next/dev/types .next/types && tsc -p tsconfig.typecheck.json --noEmit", "typecheck:fandom": "tsc -p tsconfig.typecheck.fandom.json --noEmit", "validate:quick": "pnpm run generated:check && pnpm exec vitest run -c vitest.config.mts tests/validation.test.ts tests/shared-env-contract.test.ts tests/safe-next-build.test.ts", - "lint": "eslint", + "lint": "NODE_OPTIONS=--max-old-space-size=8192 eslint", "clean": "rm -rf .next", "generated:check": "node scripts/generate-admin-api-references.mjs --check && node scripts/generate-brand-font-artifacts.mjs --check", "generate:brand-font-artifacts": "node scripts/generate-brand-font-artifacts.mjs", "generate:brand-font-artifacts:with-glyphs": "node scripts/generate-brand-font-artifacts.mjs --with-glyph-comparison", "brand-fonts:visual-similarity": "node scripts/brand-font-visual-similarity.mjs", "brand-fonts:glyph-comparison": "node scripts/brand-font-glyph-comparison.mjs", + "perf:admin:api": "node scripts/perf-admin-api.mjs", "design-docs:classify": "node scripts/design-docs/classify-publisher-patterns.mjs", "design-docs:extract-navigation": "node scripts/design-docs/extract-navigation.mjs", "design-docs:nyt-games:media": "node scripts/design-docs/generate-nyt-games-media-artifacts.mjs", @@ -45,38 +46,40 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@firebase/app": "0.14.13", + "@firebase/app": "0.15.0", "@firebase/component": "0.7.3", - "@firebase/firestore": "4.15.0", + "@firebase/firestore": "4.16.0", "@firebase/logger": "0.5.1", "@firebase/util": "1.15.1", - "@sentry/nextjs": "^10.58.0", - "@supabase/supabase-js": "^2.108.2", - "@vercel/functions": "^3.7.1", + "@sentry/nextjs": "^10.62.0", + "@supabase/supabase-js": "^2.110.0", + "@vercel/analytics": "^2.0.1", + "@vercel/functions": "^3.7.4", + "@vercel/speed-insights": "^2.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "firebase": "^12.14.0", - "firebase-admin": "^14.0.0", - "framer-motion": "^12.40.0", + "firebase": "^12.15.0", + "firebase-admin": "^14.1.0", + "framer-motion": "^12.42.1", "html-to-image": "^1.11.13", - "lucide-react": "^1.18.0", + "lucide-react": "^1.22.0", "next": "16.2.9", - "pg": "^8.21.0", - "radix-ui": "^1.5.0", + "pg": "^8.22.0", + "radix-ui": "^1.6.0", "react": "19.2.7", "react-dom": "19.2.7", - "shadcn": "^4.11.0", + "shadcn": "^4.12.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", - "undici": "^8.4.1" + "undici": "^8.5.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", - "@playwright/test": "^1.61.0", - "@tailwindcss/postcss": "^4.3.1", + "@playwright/test": "^1.61.1", + "@tailwindcss/postcss": "^4.3.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", - "@types/node": "^25.9.3", + "@types/node": "^25.9.4", "@types/pg": "^8.20.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", @@ -85,9 +88,9 @@ "eslint": "^9.39.4", "eslint-config-next": "16.2.9", "jsdom": "^29.1.1", - "tailwindcss": "^4.3.1", + "tailwindcss": "^4.3.2", "typescript": "^5.9.3", - "vite": "^7.3.5", + "vite": "^7.3.6", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.9", "vitest-axe": "^0.1.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 12fedb4c..9ffdd98a 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; const PORT = Number.parseInt(process.env.PLAYWRIGHT_PORT ?? "3200", 10); const LIVE_MODE = process.env.E2E_CAST_LIVE === "1"; -const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${PORT}`; +const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`; const STORAGE_STATE = process.env.PLAYWRIGHT_STORAGE_STATE; export default defineConfig({ @@ -30,7 +30,7 @@ export default defineConfig({ webServer: LIVE_MODE ? undefined : { - command: `ADMIN_APP_ORIGIN=http://127.0.0.1:${PORT} ADMIN_APP_HOSTS=127.0.0.1,localhost,admin.localhost NEXT_PUBLIC_DEV_ADMIN_BYPASS=true NEXT_DIST_DIR=.next-e2e pnpm exec next dev --webpack -p ${PORT}`, + command: `ADMIN_APP_ORIGIN=http://localhost:${PORT} ADMIN_APP_HOSTS=localhost,127.0.0.1,admin.localhost NEXT_PUBLIC_DEV_ADMIN_BYPASS=true NEXT_DIST_DIR=.next-e2e pnpm exec next dev --webpack -p ${PORT}`, url: BASE_URL, timeout: 240_000, reuseExistingServer: false, diff --git a/apps/web/scripts/perf-admin-api.mjs b/apps/web/scripts/perf-admin-api.mjs new file mode 100644 index 00000000..af2814b2 --- /dev/null +++ b/apps/web/scripts/perf-admin-api.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const appRoot = resolve(__dirname, ".."); +const workspaceRoot = resolve(appRoot, "../../.."); +const defaultOutputRoot = resolve(workspaceRoot, ".artifacts/perf/autocannon"); +const defaultCaPath = `${process.env.HOME ?? ""}/.portless/ca.pem`; + +const presets = { + "social-snapshot": "/api/admin/trr-api/social/profiles/instagram/bravotv/snapshot", + "social-catalog-detail": "/api/admin/trr-api/social/profiles/instagram/bravotv/snapshot?detail=catalog", + "comments-summary": "/api/admin/trr-api/social/profiles/instagram/bravotv/comments/summary", + "cast-data": "/api/admin/trr-api/shows/rhoslc/cast", + "credits-data": "/api/admin/trr-api/shows/rhoslc/credits", + "cast-credits": "/api/admin/trr-api/shows/rhoslc/cast-credits", +}; + +function printHelp() { + console.log(`Usage: pnpm run perf:admin:api -- [options] + +Runs a low-concurrency autocannon benchmark against a clean Portless admin URL. + +Options: + --preset Preset route. Default: social-snapshot + --url Explicit https://admin.trr.localhost/... URL override + --connections Autocannon connections. Default: 2 + --duration Autocannon duration. Default: 10 + --max-p95-ms Fail when parsed p95 latency exceeds this value + --max-p99-ms Fail when parsed p99 latency exceeds this value + --out Output root. Default: .artifacts/perf/autocannon + --print-target Print the resolved target and exit + --help Show this help + +Presets: ${Object.keys(presets).join(", ")}`); +} + +function readArgs(argv) { + const options = { + preset: "social-snapshot", + connections: "2", + duration: "10", + out: defaultOutputRoot, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = () => { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${arg} requires a value`); + } + index += 1; + return value; + }; + + if (arg === "--help" || arg === "-h") options.help = true; + else if (arg === "--") continue; + else if (arg === "--print-target") options.printTarget = true; + else if (arg === "--preset") options.preset = next(); + else if (arg === "--url") options.url = next(); + else if (arg === "--connections") options.connections = next(); + else if (arg === "--duration") options.duration = next(); + else if (arg === "--max-p95-ms") options.maxP95Ms = Number(next()); + else if (arg === "--max-p99-ms") options.maxP99Ms = Number(next()); + else if (arg === "--out") options.out = resolve(next()); + else throw new Error(`Unknown option: ${arg}`); + } + + return options; +} + +function resolveTarget(options) { + if (options.url) { + const parsed = new URL(options.url); + if (parsed.protocol !== "https:" || parsed.hostname !== "admin.trr.localhost") { + throw new Error("--url must use https://admin.trr.localhost"); + } + return parsed.toString(); + } + + const route = presets[options.preset]; + if (!route) { + throw new Error(`Unknown preset: ${options.preset}`); + } + return new URL(route, "https://admin.trr.localhost").toString(); +} + +function percentileMs(result, key) { + const latency = result.latency; + if (!latency || typeof latency !== "object") return null; + const value = latency[key] ?? latency[`p${key}`]; + return typeof value === "number" ? value : null; +} + +try { + const options = readArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + process.exit(0); + } + + const target = resolveTarget(options); + if (options.printTarget) { + console.log(target); + process.exit(0); + } + + const timestamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+$/, "Z"); + const outputDir = resolve(options.out, timestamp); + mkdirSync(outputDir, { recursive: true }); + + const env = { ...process.env }; + if (!env.NODE_EXTRA_CA_CERTS && defaultCaPath.startsWith("/")) { + env.NODE_EXTRA_CA_CERTS = defaultCaPath; + } + + const result = spawnSync( + "npx", + ["--yes", "autocannon", "--connections", options.connections, "--duration", options.duration, "--latency", "--json", target], + { + cwd: appRoot, + env, + encoding: "utf8", + }, + ); + + const resultPath = resolve(outputDir, "result.json"); + writeFileSync(resultPath, result.stdout || "", "utf8"); + if (result.stderr) { + writeFileSync(resolve(outputDir, "stderr.log"), result.stderr, "utf8"); + } + writeFileSync( + resolve(outputDir, "metadata.json"), + JSON.stringify( + { + target, + preset: options.url ? null : options.preset, + connections: Number(options.connections), + duration_seconds: Number(options.duration), + generated_at: new Date().toISOString(), + }, + null, + 2, + ), + "utf8", + ); + + const latestPath = resolve(options.out, "latest"); + rmSync(latestPath, { force: true, recursive: true }); + symlinkSync(outputDir, latestPath, "dir"); + + if (result.status !== 0) { + process.stderr.write(result.stderr || "autocannon failed\n"); + process.exit(result.status ?? 1); + } + + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch { + throw new Error(`autocannon output was not valid JSON: ${resultPath}`); + } + + const p95 = percentileMs(parsed, "p95"); + const p99 = percentileMs(parsed, "p99"); + const failures = []; + if (typeof options.maxP95Ms === "number" && p95 !== null && p95 > options.maxP95Ms) { + failures.push(`p95 ${p95}ms exceeded ${options.maxP95Ms}ms`); + } + if (typeof options.maxP99Ms === "number" && p99 !== null && p99 > options.maxP99Ms) { + failures.push(`p99 ${p99}ms exceeded ${options.maxP99Ms}ms`); + } + + console.log(`autocannon result: ${resultPath}`); + console.log(`target: ${target}`); + if (p95 !== null || p99 !== null) { + console.log(`latency: p95=${p95 ?? "n/a"}ms p99=${p99 ?? "n/a"}ms`); + } + if (failures.length > 0) { + console.error(failures.join("\n")); + process.exit(1); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/apps/web/src/app/admin/dev-dashboard/_components/DevDashboardShell.tsx b/apps/web/src/app/admin/dev-dashboard/_components/DevDashboardShell.tsx index cc54bb43..04458d54 100644 --- a/apps/web/src/app/admin/dev-dashboard/_components/DevDashboardShell.tsx +++ b/apps/web/src/app/admin/dev-dashboard/_components/DevDashboardShell.tsx @@ -10,6 +10,7 @@ import { ADMIN_ROOT_PATH } from "@/lib/admin/admin-route-paths"; const DEV_DASHBOARD_ROUTES = [ { href: "/dev-dashboard", label: "Overview" }, { href: "/dev-dashboard/skills-and-agents", label: "Skills & Agents" }, + { href: "/dev-dashboard/instagram-catalog-backfill-mockup", label: "Catalog Backfill" }, ] as const; interface DevDashboardShellProps { diff --git a/apps/web/src/app/admin/dev-dashboard/instagram-catalog-backfill-mockup/page.tsx b/apps/web/src/app/admin/dev-dashboard/instagram-catalog-backfill-mockup/page.tsx new file mode 100644 index 00000000..8294679f --- /dev/null +++ b/apps/web/src/app/admin/dev-dashboard/instagram-catalog-backfill-mockup/page.tsx @@ -0,0 +1,12 @@ +import { InstagramCatalogBackfillMockupView } from "@/components/admin/instagram/InstagramCatalogBackfillMockupView"; + +export default function InstagramCatalogBackfillMockupPage() { + return ( + + ); +} diff --git a/apps/web/src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx b/apps/web/src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx new file mode 100644 index 00000000..2ce62d21 --- /dev/null +++ b/apps/web/src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx @@ -0,0 +1,733 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIcon, + AlertTriangleIcon, + ArrowLeftIcon, + CheckCircle2Icon, + Clock3Icon, + ExternalLinkIcon, + FilterIcon, + PlayIcon, + SearchIcon, +} from "lucide-react"; +import Link from "next/link"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { fetchAdminWithAuth as fetchAdminWithAuthBase } from "@/lib/admin/client-auth"; +import { useAdminGuard } from "@/lib/admin/useAdminGuard"; +import type { + SocialAccountCommentsRunProgress, + SocialAccountCommentsTargetProgressRow, + SocialAccountProfileSummary, +} from "@/lib/admin/social-account-profile"; + +const navItems = ["Overview", "Active run", "Workers", "Coverage", "Actions"]; +const PLATFORM = "instagram"; +const HANDLE = "bravotv"; +const REFRESH_INTERVAL_MS = 5_000; +const ACTIVE_RUN_STATUSES = new Set(["queued", "pending", "retrying", "running", "cancelling"]); + +type ProxyErrorPayload = { + error?: string; + message?: string; + detail?: string | { message?: string }; +}; + +type WorkerHealthRow = { + group: string; + count: number; + status: string; + detail: string; + action: string; +}; + +type MovementRow = { + label: string; + title: string; + detail: string; +}; + +type CoverageRow = { + post: string; + saved: string; + gap: string; + issue: string; + next: string; +}; + +const formatInteger = (value: number | null | undefined): string => { + const numeric = Number(value); + return new Intl.NumberFormat("en-US").format(Number.isFinite(numeric) ? numeric : 0); +}; + +const readFiniteNumber = (value: unknown): number | null => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +}; + +const readRecord = (value: unknown): Record | null => { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +}; + +const firstFiniteNumber = (...values: unknown[]): number | null => { + for (const value of values) { + const numeric = readFiniteNumber(value); + if (numeric !== null) return numeric; + } + return null; +}; + +const readRecordNumber = (record: Record | null | undefined, keys: string[]): number | null => { + if (!record) return null; + for (const key of keys) { + const numeric = readFiniteNumber(record[key]); + if (numeric !== null) return numeric; + } + return null; +}; + +const formatStatusLabel = (value?: string | null): string => { + const normalized = String(value || "").trim(); + if (!normalized) return "Idle"; + return normalized.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); +}; + +const formatIssueLabel = (value?: string | null): string => { + const normalized = String(value || "").trim(); + if (!normalized) return "Unknown"; + return normalized.replace(/_/g, " "); +}; + +const formatDurationEstimate = (seconds: number | null): string => { + if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return "Unknown"; + const roundedMinutes = Math.max(1, Math.round(seconds / 60)); + const hours = Math.floor(roundedMinutes / 60); + const minutes = roundedMinutes % 60; + if (hours <= 0) return `${minutes}m`; + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +}; + +const formatSecondsPerPost = (seconds: number | null): string => { + if (seconds === null || !Number.isFinite(seconds) || seconds <= 0) return "Unknown pace"; + if (seconds < 10) return `${seconds.toFixed(1)} sec per post`; + return `${Math.round(seconds)} sec per post`; +}; + +const toProgressPercent = (completed: number | null, total: number | null): number => { + if (completed === null || total === null || total <= 0) return 0; + return Math.max(0, Math.min(100, (completed / total) * 100)); +}; + +const mapRecordToTargetProgressRow = (row: Record): SocialAccountCommentsTargetProgressRow => ({ + source_id: typeof row.source_id === "string" ? row.source_id : typeof row.post_id === "string" ? row.post_id : null, + shortcode: typeof row.shortcode === "string" ? row.shortcode : typeof row.code === "string" ? row.code : null, + latest_reason: + typeof row.latest_reason === "string" + ? row.latest_reason + : typeof row.reason === "string" + ? row.reason + : typeof row.issue === "string" + ? row.issue + : null, + latest_stop_reason: typeof row.latest_stop_reason === "string" ? row.latest_stop_reason : null, + reported_comment_count: readFiniteNumber(row.reported_comment_count ?? row.reported_comments), + saved_comment_count: readFiniteNumber(row.saved_comment_count ?? row.saved_comments), + observed_comment_count: readFiniteNumber(row.observed_comment_count ?? row.observed_comments), + missing_comment_gap: readFiniteNumber(row.missing_comment_gap ?? row.gap), + retryable: typeof row.retryable === "boolean" ? row.retryable : undefined, + network_stopped: typeof row.network_stopped === "boolean" ? row.network_stopped : undefined, +}); + +const getProgressTargetRows = (progress: SocialAccountCommentsRunProgress | null): SocialAccountCommentsTargetProgressRow[] => { + const rows = progress?.target_progress_rows ?? progress?.target_progress ?? progress?.retry_progress?.target_progress_rows ?? []; + if (Array.isArray(rows) && rows.length > 0) return rows.slice(0, 4); + const fallbackRows = + progress?.retry_progress?.largest_remaining_gaps ?? + progress?.largest_remaining_gaps ?? + progress?.largest_gaps ?? + progress?.incomplete_targets ?? + []; + return Array.isArray(fallbackRows) ? fallbackRows.slice(0, 4).map(mapRecordToTargetProgressRow) : []; +}; + +const buildRequestError = (payload: ProxyErrorPayload, fallback: string): Error => { + const detailMessage = typeof payload.detail === "object" ? payload.detail?.message : payload.detail; + return new Error(payload.error || payload.message || detailMessage || fallback); +}; + +export default function InstagramCommentsMockupPage() { + const { user, checking, hasAccess } = useAdminGuard(); + const [summary, setSummary] = useState(null); + const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const hasLoadedRef = useRef(false); + const handle = HANDLE; + const canonicalCommentsUrl = "/social/instagram/bravotv/comments"; + const fetchAdminWithAuth = useCallback( + ( + input: RequestInfo | URL, + init?: RequestInit, + options?: Parameters[2], + ) => + fetchAdminWithAuthBase(input, init, { + ...options, + preferredUser: options?.preferredUser ?? user, + allowDevAdminBypass: options?.allowDevAdminBypass ?? true, + }), + [user], + ); + const refreshDashboard = useCallback(async (options?: { showLoading?: boolean }) => { + if (checking || !user || !hasAccess) { + setLoading(false); + return; + } + if (options?.showLoading || !hasLoadedRef.current) setLoading(true); + setError(null); + try { + const summaryResponse = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${PLATFORM}/${HANDLE}/summary?detail=lite`, + undefined, + { preferredUser: user }, + ); + const summaryPayload = (await summaryResponse.json().catch(() => ({}))) as SocialAccountProfileSummary & ProxyErrorPayload; + if (!summaryResponse.ok) { + throw buildRequestError(summaryPayload, "Failed to load comments summary"); + } + setSummary(summaryPayload); + const activeRunId = String(summaryPayload.comments_coverage?.active_run_id || "").trim(); + if (activeRunId) { + const progressResponse = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${PLATFORM}/${HANDLE}/comments/runs/${encodeURIComponent(activeRunId)}/progress`, + undefined, + { preferredUser: user }, + ); + const progressPayload = (await progressResponse.json().catch(() => ({}))) as SocialAccountCommentsRunProgress & + ProxyErrorPayload; + if (!progressResponse.ok) { + throw buildRequestError(progressPayload, "Failed to load comments run progress"); + } + setProgress(progressPayload); + } else { + setProgress(null); + } + setLastUpdatedAt(new Date()); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Failed to load live comments data"); + } finally { + hasLoadedRef.current = true; + setLoading(false); + } + }, [checking, fetchAdminWithAuth, hasAccess, user]); + + useEffect(() => { + if (checking || !hasAccess || !user) return; + let cancelled = false; + const load = async () => { + if (cancelled) return; + await refreshDashboard(); + }; + void load(); + const intervalId = window.setInterval(() => { + void load(); + }, REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [checking, hasAccess, refreshDashboard, user]); + + const coverage = summary?.comments_coverage ?? null; + const savedSummary = summary?.comments_saved_summary ?? null; + const runStatus = String(progress?.run_status || coverage?.effective_status || coverage?.last_comments_run_status || "").trim(); + const normalizedRunStatus = runStatus.toLowerCase(); + const runIsActive = ACTIVE_RUN_STATUSES.has(normalizedRunStatus); + const runId = String(progress?.run_id || coverage?.active_run_id || "").trim(); + const runIdLabel = runId ? runId.slice(0, 8) : "none"; + const progressSummary = readRecord(progress?.summary); + const completedPosts = firstFiniteNumber(progress?.post_progress?.completed_posts, progress?.post_progress?.matched_posts); + const totalPosts = firstFiniteNumber(progress?.post_progress?.total_posts, progress?.target_source_ids_count, coverage?.eligible_posts); + const progressPercent = toProgressPercent(completedPosts, totalPosts); + const commentsProcessed = readRecordNumber(progressSummary, ["comments_processed_total", "items_found_total"]); + const commentsInserted = readRecordNumber(progressSummary, ["comments_inserted_total", "new_comments_total"]); + const savedComments = firstFiniteNumber(savedSummary?.saved_comments); + const reportedComments = firstFiniteNumber(savedSummary?.retrieved_comments); + const openGap = + savedComments !== null && reportedComments !== null + ? Math.max(0, reportedComments - savedComments) + : firstFiniteNumber(coverage?.missing_posts, 0); + const postsPerMinute = firstFiniteNumber(progress?.throughput?.posts_per_minute); + const estimatedSecondsRemaining = + firstFiniteNumber(progress?.throughput?.estimated_seconds_remaining) ?? + (postsPerMinute !== null && postsPerMinute > 0 && completedPosts !== null && totalPosts !== null + ? (Math.max(0, totalPosts - completedPosts) / postsPerMinute) * 60 + : null); + const etaLabel = formatDurationEstimate(estimatedSecondsRemaining); + const averageSecondsPerPost = firstFiniteNumber(progress?.throughput?.average_seconds_per_post); + const runningWorkers = firstFiniteNumber(progress?.running_comment_jobs, progress?.active_comment_jobs, progress?.worker_counters?.running, progress?.worker_counters?.active) ?? 0; + const retryingWorkers = firstFiniteNumber(progress?.retrying_comment_jobs, progress?.worker_counters?.retrying) ?? 0; + const failedWorkers = firstFiniteNumber(progress?.failed_comment_jobs, progress?.worker_counters?.failed) ?? 0; + const queuedWorkers = firstFiniteNumber(progress?.queued_comment_jobs, progress?.worker_counters?.queued) ?? 0; + const shardCount = + firstFiniteNumber(progress?.comments_shard_count, progress?.worker_counters?.total) ?? + runningWorkers + retryingWorkers + failedWorkers + queuedWorkers; + const attentionCount = failedWorkers + retryingWorkers; + const attentionTitle = + attentionCount > 0 + ? `${formatInteger(failedWorkers)} failed shard, ${formatInteger(retryingWorkers)} retrying shard` + : "No shard action needed"; + const attentionDetail = + attentionCount > 0 + ? "The whole run is not failed. Cancel only a shard that stays stuck after another refresh." + : "Workers look healthy. Let the run continue."; + const proof = progress?.instagram_access_proof; + const authState = String(proof?.auth_state || proof?.cookie_state || "").trim(); + const authLabel = proof?.no_cookies ? "No cookies" : authState ? formatStatusLabel(authState) : "Not reported"; + const networkMode = proof?.proof_label || [ + proof?.no_cookies ? "No cookies" : proof?.cookie_state, + proof?.no_decodo ? "no Decodo" : proof?.decodo_state, + ].filter(Boolean).join(" · ") || "Live progress source"; + const workerRows = useMemo( + () => [ + { + group: "Running", + count: runningWorkers, + status: runningWorkers > 0 ? "Healthy" : "Idle", + detail: runningWorkers > 0 ? "Workers are saving comments" : "No active comments worker reported", + action: runningWorkers > 0 ? "Leave running" : "Wait", + }, + { + group: "Retrying", + count: retryingWorkers, + status: retryingWorkers > 0 ? "Watch" : "Clear", + detail: retryingWorkers > 0 ? "Retry is expected during public relay blocks" : "No retrying shard reported", + action: retryingWorkers > 0 ? "Wait" : "No action", + }, + { + group: "Failed", + count: failedWorkers, + status: failedWorkers > 0 ? "Needs check" : "Clear", + detail: failedWorkers > 0 ? "Cancel only if it stays failed without movement" : "No failed shard reported", + action: failedWorkers > 0 ? "Inspect shard" : "No action", + }, + { + group: "Queued", + count: queuedWorkers, + status: queuedWorkers > 0 ? "Waiting" : "Clear", + detail: queuedWorkers > 0 ? "Queued behind active workers" : "No queued shard reported", + action: queuedWorkers > 0 ? "No action" : "No action", + }, + ], + [failedWorkers, queuedWorkers, retryingWorkers, runningWorkers], + ); + const metrics = useMemo( + () => [ + { + label: "Saved comments", + value: formatInteger(savedComments), + detail: + commentsInserted !== null && commentsInserted > 0 + ? `${formatInteger(commentsInserted)} new this run` + : "Live saved total", + }, + { + label: "Open gap", + value: formatInteger(openGap), + detail: "Reported minus saved", + }, + { + label: "Posts checked", + value: + completedPosts !== null && totalPosts !== null + ? `${formatInteger(completedPosts)} / ${formatInteger(totalPosts)}` + : "Waiting", + detail: progress?.incomplete_fill ? "Incomplete fill scope" : "Active run scope", + }, + { + label: "Estimated finish", + value: etaLabel, + detail: formatSecondsPerPost(averageSecondsPerPost), + }, + ], + [averageSecondsPerPost, commentsInserted, completedPosts, etaLabel, openGap, progress?.incomplete_fill, savedComments, totalPosts], + ); + const movement = useMemo( + () => [ + { + label: "Now", + title: runIsActive ? "Run is active" : runStatus ? `Run is ${formatStatusLabel(runStatus).toLowerCase()}` : "No active run", + detail: + runningWorkers > 0 + ? `${formatInteger(runningWorkers)} workers are running and comments are still being fetched.` + : "No running comments worker is currently reported.", + }, + { + label: "Last refresh", + title: + commentsProcessed !== null + ? `${formatInteger(commentsProcessed)} comments fetched` + : "Waiting for comments movement", + detail: + commentsInserted !== null + ? `${formatInteger(commentsInserted)} were new saved comments.` + : "New saved comment count is not present in the latest progress payload.", + }, + { + label: "Next", + title: attentionCount > 0 ? "Watch shard movement" : "Let the run continue", + detail: progress?.operator_next_action || progress?.recommended_next_action || progress?.recommended_action || attentionDetail, + }, + ], + [ + attentionCount, + attentionDetail, + commentsInserted, + commentsProcessed, + progress?.operator_next_action, + progress?.recommended_action, + progress?.recommended_next_action, + runIsActive, + runStatus, + runningWorkers, + ], + ); + const coverageRows = useMemo(() => { + const rows = getProgressTargetRows(progress); + return rows.map((row, index) => { + const post = String(row.shortcode || row.source_id || `target-${index + 1}`); + const saved = firstFiniteNumber(row.saved_comment_count, row.observed_comment_count) ?? 0; + const reported = firstFiniteNumber(row.reported_comment_count) ?? 0; + const gap = firstFiniteNumber(row.missing_comment_gap) ?? Math.max(0, reported - saved); + const reason = row.network_stopped ? "Network stopped" : formatIssueLabel(row.latest_reason || row.fetch_reason || row.latest_stop_reason || row.cursor_stop_reason); + return { + post, + saved: `${formatInteger(saved)} / ${formatInteger(reported)}`, + gap: formatInteger(gap), + issue: reason, + next: row.retryable === false ? "Inspect manually" : "Retry automatically", + }; + }); + }, [progress]); + const displayedCoverageRows = + coverageRows.length > 0 + ? coverageRows + : [ + { + post: "No target rows", + saved: "0 / 0", + gap: "0", + issue: progress ? "Latest progress payload has no target rows" : "Waiting for active progress", + next: "Refresh", + }, + ]; + const lastUpdatedLabel = lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : "Not loaded yet"; + + return ( +
+
+ + +
+
+
+
+ +
+
+ + +
+
+ + {error ? ( + + + ) : null} + +
+ + + Active comments run + + Run {runIdLabel} · {progress?.incomplete_fill ? "incomplete fill" : "comments sync"} · {formatInteger(shardCount)} shards · {networkMode} + + + {formatInteger(runningWorkers)} workers running + + + +
+
+ + {completedPosts !== null && totalPosts !== null + ? `${formatInteger(completedPosts)} of ${formatInteger(totalPosts)} posts checked` + : "Waiting for post progress"} + + {Math.round(progressPercent)}% +
+ +
+ +
+ {metrics.map((metric) => ( +
+

{metric.label}

+

{metric.value}

+

{metric.detail}

+
+ ))} +
+ + + +
+
+ + + + Needs attention + Only one item needs a decision right now. + + +
+
+ +
+
+ Auth state + + {authLabel} + +
+
+ Network mode + {networkMode} +
+
+ Next action + {attentionCount > 0 ? "Watch" : "Wait"} +
+
+
+
+
+ +
+ + + Worker health + Grouped by what an operator should do, not by raw shard logs. + + +
+ + + + Group + Status + Plain-language detail + Action + + + + {workerRows.map((row) => ( + + + {formatInteger(row.count)} {row.group.toLowerCase()} + + + {row.status} + + {row.detail} + {row.action} + + ))} + +
+
+
+
+ + + + Recent movement + Short updates, no duplicate run summaries. + + +
    + {movement.map((item, index) => ( +
  1. +
    {item.label}
    +
    + {index < movement.length - 1 ? ( +
    +
  2. + ))} +
+
+
+
+ + + + Coverage queue + Posts with the largest open comment gaps. + + + +
+ + + + Post + Saved + Gap + Issue + Next step + + + + {displayedCoverageRows.map((row) => ( + + {row.post} + {row.saved} + {row.gap} + {row.issue} + + + + + ))} + +
+
+
+
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/admin/dev-dashboard/page.tsx b/apps/web/src/app/admin/dev-dashboard/page.tsx index b1e7442b..d27f4817 100644 --- a/apps/web/src/app/admin/dev-dashboard/page.tsx +++ b/apps/web/src/app/admin/dev-dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import ClientOnly from "@/components/ClientOnly"; import { buildAdminSectionBreadcrumb } from "@/lib/admin/admin-breadcrumbs"; import { fetchAdminWithAuth } from "@/lib/admin/client-auth"; @@ -105,9 +106,94 @@ interface OutstandingTasks { claudePlans: ClaudePlanItem[]; } +interface VercelPreviewReadinessCheck { + status: number | null; + stdout: string; + stderr: string; + enabled: boolean | null; +} + +interface VercelPreviewReadiness { + artifactPath: string; + generatedAt: string | null; + projectName: string | null; + teamSlug: string | null; + teamId: string | null; + activeProjectDir: string | null; + latestDeploymentUrl: string | null; + webAnalyticsEnabled: boolean | null; + speedInsightsEnabled: boolean | null; + checks: { + webAnalytics: VercelPreviewReadinessCheck | null; + speedInsights: VercelPreviewReadinessCheck | null; + deployments: VercelPreviewReadinessCheck | null; + }; + errors: string[]; +} + +interface VercelCleanupLink { + ok: boolean; + classification: string; + projectDir: string; + projectFile: string; + projectName: string; + projectId: string; + teamId: string; + cleanupPath: string; + error?: string; +} + +interface VercelCleanupDoctor { + ok: boolean; + expectedName: string; + expectedId: string; + links: VercelCleanupLink[]; + errors: string[]; +} + +interface PortlessServiceStatus { + manager_state: string | null; + installed: boolean | null; + proxy_on_443: boolean | null; + https: boolean | null; + tld: string | null; + lan_mode: boolean | null; + wildcard: boolean | null; + state_directory: string | null; + service_entry: string | null; + raw_output: string; + raw_error?: string; +} + +interface PortlessRouteStatus { + id: string; + label: string; + url: string; + expectedPath: string; + target: string | null; + kind: string | null; + present: boolean; + staticAlias: boolean; +} + +interface PortlessStatusSnapshot { + status: "ok" | "unavailable"; + checked_at: string; + service: PortlessServiceStatus | null; + routes: PortlessRouteStatus[]; + static_alias_count: number; + uses_static_aliases: boolean; + repair_command: string; + open_admin_command: string; + raw_error?: string; +} + interface DevDashboardData { repos: RepoStatus[]; tasks: OutstandingTasks; + portlessStatus: PortlessStatusSnapshot; + vercelPreviewReadiness: VercelPreviewReadiness | null; + vercelCleanupDoctor: VercelCleanupDoctor; generatedAt: string; } @@ -225,6 +311,19 @@ function getReadinessBadge(ready: boolean) { return { label: "Not Ready", className: "border border-amber-200 bg-amber-50 text-amber-800" }; } +function getVercelReadinessBadge(enabled: boolean | null, status: number | null) { + if (status !== null && status !== 0) { + return { label: "Error", className: "border border-red-200 bg-red-50 text-red-800" }; + } + if (enabled === true) { + return { label: "Enabled", className: "border border-green-200 bg-green-50 text-green-800" }; + } + if (enabled === false) { + return { label: "Disabled", className: "border border-amber-200 bg-amber-50 text-amber-800" }; + } + return { label: "Unknown", className: "border border-zinc-200 bg-zinc-100 text-zinc-700" }; +} + // ============================================================================ // Page // ============================================================================ @@ -465,6 +564,40 @@ export default function DevDashboardPage() { ) : null} + {data.vercelPreviewReadiness || data.vercelCleanupDoctor ? ( + + ) : null} + + + +
+
+
+

Instagram Catalog Backfill

+

+ Test the cleaner live-run layout from the dev copy or from the @bravotv catalog route. +

+
+
+ + Open Mockup + + + Open Alt 1 + +
+
+
+
{REPO_TABS.map((tab) => { const isActive = tab === activeRepo; @@ -568,6 +701,248 @@ function ErrorsSection({ errors }: { errors: string[] }) { ); } +function VercelWorkspaceSection({ + readiness, + cleanupDoctor, +}: { + readiness: VercelPreviewReadiness | null; + cleanupDoctor: VercelCleanupDoctor | null; +}) { + const checks = [ + { label: "Web Analytics", enabled: readiness?.webAnalyticsEnabled ?? null, check: readiness?.checks.webAnalytics ?? null }, + { label: "Speed Insights", enabled: readiness?.speedInsightsEnabled ?? null, check: readiness?.checks.speedInsights ?? null }, + ]; + const staleLinks = cleanupDoctor?.links.filter((link) => !link.ok) ?? []; + + return ( +
+
+
+

Vercel Preview Readiness

+

+ Project: {readiness?.projectName ?? cleanupDoctor?.expectedName ?? "unknown"} + {" · "}Team: {readiness?.teamSlug ?? readiness?.teamId ?? "unknown"} +

+

+ Generated:{" "} + + {readiness?.generatedAt ? `${new Date(readiness.generatedAt).toLocaleString()} (${formatRelativeTime(readiness.generatedAt)})` : "unknown"} + +

+
+ + latest.json + +
+ + {readiness ? ( + <> +
+ {checks.map((item) => { + const badge = getVercelReadinessBadge(item.enabled, item.check?.status ?? null); + return ( +
+
+

{item.label}

+ + {badge.label} + +
+

+ Exit: {item.check?.status ?? "unknown"} +

+
+ ); + })} +
+ +
+

+ Artifact: {readiness.artifactPath} +

+

+ Active project dir:{" "} + {readiness.activeProjectDir ?? "unknown"} +

+

+ Latest deployment:{" "} + {readiness.latestDeploymentUrl ? ( + + {readiness.latestDeploymentUrl} + + ) : ( + unknown + )} +

+
+ + ) : ( +

No preview readiness artifact found.

+ )} + + {cleanupDoctor ? ( +
+
+
+

Vercel Cleanup Doctor

+

+ Expected: {cleanupDoctor.expectedName}{" "} + ({cleanupDoctor.expectedId}) +

+
+ + {cleanupDoctor.ok ? "Clean" : `${staleLinks.length} cleanup`} + +
+
    + {cleanupDoctor.links.length > 0 ? ( + cleanupDoctor.links.map((link) => ( +
  • + {link.projectName || "unknown"}{" "} + ({link.projectId || "unknown"}) + {" · "} + {link.classification} + {" · "} + {link.projectFile} + {!link.ok ? ( + <> + {" · cleanup "} + {link.cleanupPath} + + ) : null} +
  • + )) + ) : ( +
  • No local Vercel project links found.
  • + )} +
+ {cleanupDoctor.errors.length > 0 ? ( +

{cleanupDoctor.errors.join(" | ")}

+ ) : null} +
+ ) : null} + + {readiness && readiness.errors.length > 0 ? ( +
+

Command Errors

+
    + {readiness.errors.map((error) => ( +
  • + {error} +
  • + ))} +
+
+ ) : null} +
+ ); +} + +function PortlessWorkspaceSection({ snapshot }: { snapshot: PortlessStatusSnapshot | null }) { + const service = snapshot?.service ?? null; + const routes = snapshot?.routes ?? []; + const routeById = new Map(routes.map((route) => [route.id, route])); + const appRouteReady = routeById.get("trr")?.present === true; + const adminRouteReady = routeById.get("admin.trr")?.present === true; + const apiRouteReady = routeById.get("api.trr")?.present === true; + const serviceReady = + service?.installed === true && service.proxy_on_443 === true && service.wildcard === true; + const ready = + snapshot?.status === "ok" && + serviceReady && + appRouteReady && + adminRouteReady && + apiRouteReady && + snapshot.uses_static_aliases !== true; + const blockers = [ + service?.installed === false ? "Portless service is not installed" : null, + service?.proxy_on_443 === false ? "Portless secure proxy is not responding" : null, + service?.wildcard === false ? "Wildcard routing is off" : null, + !appRouteReady ? "TRR app route is missing" : null, + !apiRouteReady ? "TRR API route is missing" : null, + snapshot?.uses_static_aliases ? "Static aliases are still active" : null, + snapshot?.status === "unavailable" ? snapshot.raw_error || "Portless route list is unavailable" : null, + ].filter((item): item is string => Boolean(item)); + + return ( +
+
+
+

Portless Browser Readiness

+

+ Clean admin URL: https://admin.trr.localhost +

+ {snapshot?.checked_at ? ( +

+ Checked: {new Date(snapshot.checked_at).toLocaleString()} +

+ ) : null} +
+ + {ready ? "Ready" : "Blocked"} + +
+ +
+
+

Service

+

+ {serviceReady ? "Installed and running" : "Needs repair"} +

+

+ State: {service?.manager_state ?? "unknown"} +

+
+
+

Proxy

+

+ {service?.proxy_on_443 ? "Secure local routing is open" : "Secure local routing is closed"} +

+

+ Wildcard: {service?.wildcard ? "on" : "off"} +

+
+
+

Routes

+

+ {appRouteReady && apiRouteReady ? "App and API are connected" : "App route is not complete"} +

+

+ Admin: {adminRouteReady ? "wildcard/ready" : "missing"} +

+
+
+ + {blockers.length > 0 ? ( +
+

What is blocking Browser

+

{blockers.join(" | ")}

+

+ Repair: {snapshot?.repair_command ?? "make portless-repair"} +

+
+ ) : null} +
+ ); +} + function getBranchRecommendationBadge(rec: BranchRecommendation) { if (rec === "pr") { return { label: "Commit/PR", className: "border border-blue-200 bg-blue-50 text-blue-800" }; diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 0ddd42c0..de52d394 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -11,10 +11,10 @@ import { buildAdminRootBreadcrumb } from "@/lib/admin/admin-breadcrumbs"; import { fetchAdminWithAuth } from "@/lib/admin/client-auth"; import { ADMIN_DASHBOARD_TOOLS } from "@/lib/admin/admin-navigation"; import { + PORTLESS_API_HEALTH_URL, PORTLESS_ADMIN_DASHBOARD_URL, - PORTLESS_ADMIN_ORIGIN, - PORTLESS_API_ORIGIN, - PORTLESS_APP_ORIGIN, + PORTLESS_ROUTE_DEFINITIONS, + PORTLESS_ROUTE_LINKS, } from "@/lib/admin/admin-url-defaults"; import { useAdminGuard } from "@/lib/admin/useAdminGuard"; @@ -363,11 +363,6 @@ function PortlessUrlCard() { }; }, []); - const links = [ - ["Admin", PORTLESS_ADMIN_DASHBOARD_URL], - ["App", PORTLESS_APP_ORIGIN], - ["API", `${PORTLESS_API_ORIGIN}/health/live`], - ] as const; const usesStaticAliases = snapshot?.uses_static_aliases === true; const statusLabel = wrongAdminHost ? "Wrong host" : usesStaticAliases ? "Aliases" : state === "ready" ? "Clean" : state.toUpperCase(); @@ -385,10 +380,12 @@ function PortlessUrlCard() { setDiagnosticsState("copying"); const diagnostics = { copied_at: new Date().toISOString(), + clean_admin_url: PORTLESS_ADMIN_DASHBOARD_URL, + current_route: window.location.href, current_url: window.location.href, expected_admin_url: PORTLESS_ADMIN_DASHBOARD_URL, - expected_app_url: PORTLESS_APP_ORIGIN, - expected_api_url: `${PORTLESS_API_ORIGIN}/health/live`, + expected_app_url: PORTLESS_ROUTE_DEFINITIONS.app.url, + expected_api_url: PORTLESS_API_HEALTH_URL, wrong_admin_host: isWrongAdminHost(), status_state: state, portless_status: snapshot, @@ -427,8 +424,8 @@ function PortlessUrlCard() { {wrongAdminHost ? (

This admin page is loaded from the wrong local host. Open the clean admin dashboard at{" "} - - {PORTLESS_ADMIN_ORIGIN}/admin + + {PORTLESS_ADMIN_DASHBOARD_URL} .

@@ -447,14 +444,14 @@ function PortlessUrlCard() {
) : null}
- {links.map(([label, href]) => ( + {PORTLESS_ROUTE_LINKS.map(({ label, url }) => ( {label} - {href} + {url} ))}
@@ -469,9 +466,16 @@ function PortlessUrlCard() { > {actionLabel} -

- Current route: {currentUrl ?? "checking"} -

+
+
+
Clean entry URL
+
{PORTLESS_ADMIN_DASHBOARD_URL}
+
+
+
Current route
+
{currentUrl ?? "checking"}
+
+
{(snapshot?.routes ?? []).map((route) => (
diff --git a/apps/web/src/app/admin/social/page.tsx b/apps/web/src/app/admin/social/page.tsx index 38b5d63f..79cd61e4 100644 --- a/apps/web/src/app/admin/social/page.tsx +++ b/apps/web/src/app/admin/social/page.tsx @@ -87,6 +87,7 @@ import { resolvePreferredShowRouteSlug } from "@/lib/admin/show-route-slug"; import { fetchAdminWithAuth } from "@/lib/admin/client-auth"; import { recordAdminLoadSample } from "@/lib/admin/admin-load-samples"; import { useAdminGuard } from "@/lib/admin/useAdminGuard"; +import { getTrrAppFlags } from "@/lib/trr-app-flags"; const sectionEyebrowClass = "text-xs font-semibold uppercase tracking-[0.25em] text-zinc-500"; @@ -149,10 +150,128 @@ type SharedSourceDraft = { handle: string; displayName: string; }; +type SocialQueueStatusBucket = Record; +type SocialQueueStatusPayload = { + degraded?: boolean; + queue?: { + by_stage?: Record; + by_stage_platform?: Record>; + media_runs?: SocialMediaQueueRun[]; + media_queued_jobs?: SocialMediaQueueQueuedJob[]; + media_queued_jobs_stale_after_seconds?: number | string | null; + media_stale_claims?: { + total?: number | string | null; + by_stage?: Record; + by_platform?: Record; + stale_after_seconds?: number | string | null; + }; + error?: string | null; + }; +}; +type SocialMediaQueueRun = { + run_id?: string | null; + status?: string | null; + oldest_job_created_at?: string | null; + latest_job_at?: string | null; + active?: number | string | null; + stale?: boolean | number | string | null; + stages?: Record; +}; +type SocialMediaQueueQueuedJob = { + id?: string | null; + run_id?: string | null; + platform?: string | null; + status?: string | null; + stage?: string | null; + account_handle?: string | null; + source_id?: string | null; + post_id?: string | null; + created_at?: string | null; + available_at?: string | null; + queued_age_seconds?: number | string | null; + stale?: boolean | number | string | null; +}; +type SocialMediaQueueRecoveryHistoryRow = { + id: string; + runId: string; + stage: "media_mirror" | "comment_media_mirror" | "all"; + recoveredCount: number; + dispatchedCount: number; + resultSummary: string; + recoveredAt: string; +}; +type SocialMediaQueueDrainHistoryRow = { + id: string; + runId: string; + accountHandle: string; + stage: "media_mirror" | "comment_media_mirror" | "all"; + beforeRemaining: number; + recoveredCount: number; + dispatchedCount: number; + afterRemaining: number; + stopReason: string | null; + nextRecommendedAction: string | null; + drainedAt: string; +}; +type SocialMediaQueueSnapshotLink = { + name: string; + href: string; + createdAt: string | null; + runId: string | null; + stage: string | null; +}; +type SocialMediaQueueBadgeSummary = { + queued: number; + pending: number; + running: number; + retrying: number; + stale: number; + active: number; + staleAfterSeconds: number | null; + degraded: boolean; + stages: Array<{ + key: "media_mirror" | "comment_media_mirror"; + label: string; + queued: number; + pending: number; + running: number; + retrying: number; + stale: number; + active: number; + }>; + runs: Array<{ + runId: string; + label: string; + status: string; + active: number; + stale: boolean; + latestJobAt: string | null; + }>; + queuedJobs: Array<{ + id: string; + runId: string | null; + stage: "media_mirror" | "comment_media_mirror"; + status: string; + accountHandle: string | null; + sourceId: string | null; + queuedAgeSeconds: number; + stale: boolean; + createdAt: string | null; + }>; + queuedJobsStaleAfterSeconds: number | null; +}; type CachedLandingData = { payload: SocialLandingPayload; cachedAt: string | null; }; +const SOCIAL_MEDIA_QUEUE_STAGES = ["media_mirror", "comment_media_mirror"] as const; +const SOCIAL_MEDIA_QUEUE_STAGE_LABELS: Record<(typeof SOCIAL_MEDIA_QUEUE_STAGES)[number], string> = { + media_mirror: "Post media", + comment_media_mirror: "Comment media", +}; +const SOCIAL_MEDIA_RECOVERY_CONFIRMATION = "RECOVER MEDIA MIRROR JOBS"; +const SOCIAL_MEDIA_DRAIN_CONFIRMATION = "DRAIN BRAVO MEDIA"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const SHOW_SOCIAL_HANDLE_FIELD_BY_PLATFORM: Record = { instagram: "instagram_handle", facebook: "facebook_handle", @@ -181,6 +300,185 @@ const getPlatformIconKey = ( platform: SocialLandingPlatform, ): SocialPlatformTabIconKey => platform === "twitter" ? "twitter" : platform; +const toNonNegativeInt = (value: unknown): number => { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0; +}; + +const getFirstNonNegativeInt = ( + source: Record | null | undefined, + keys: readonly string[], +): number => { + for (const key of keys) { + const value = source?.[key]; + if (value !== null && value !== undefined) { + return toNonNegativeInt(value); + } + } + return 0; +}; + +const getFirstString = ( + source: Record | null | undefined, + keys: readonly string[], +): string | null => { + for (const key of keys) { + const value = source?.[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + return null; +}; + +const queueBucketCount = ( + bucket: SocialQueueStatusBucket | undefined, + status: string, +): number => toNonNegativeInt(bucket?.[status]); + +const buildMediaQueueRunOptions = ( + runs: readonly SocialMediaQueueRun[] | undefined, +): SocialMediaQueueBadgeSummary["runs"] => + (Array.isArray(runs) ? runs : []) + .map((run) => { + const runId = typeof run.run_id === "string" ? run.run_id.trim() : ""; + if (!UUID_PATTERN.test(runId)) return null; + const active = + toNonNegativeInt(run.active) || + SOCIAL_MEDIA_QUEUE_STAGES.reduce((total, stage) => { + const bucket = run.stages?.[stage]; + return ( + total + + queueBucketCount(bucket, "queued") + + queueBucketCount(bucket, "pending") + + queueBucketCount(bucket, "running") + + queueBucketCount(bucket, "retrying") + ); + }, 0); + const latestJobAt = + typeof run.latest_job_at === "string" && run.latest_job_at.trim() + ? run.latest_job_at.trim() + : null; + const timestamp = formatCompactTimestamp(latestJobAt); + const shortRunId = runId.slice(0, 8); + const status = + typeof run.status === "string" && run.status.trim() + ? run.status.trim().toLowerCase() + : "unknown"; + return { + runId, + label: `${shortRunId} · ${active.toLocaleString()} active${ + timestamp ? ` · ${timestamp}` : "" + }`, + status, + active, + stale: Boolean(run.stale), + latestJobAt, + }; + }) + .filter((run): run is NonNullable => Boolean(run)); + +const buildMediaQueueQueuedJobs = ( + jobs: readonly SocialMediaQueueQueuedJob[] | undefined, +): SocialMediaQueueBadgeSummary["queuedJobs"] => + (Array.isArray(jobs) ? jobs : []) + .map((job) => { + const id = typeof job.id === "string" ? job.id.trim() : ""; + const stage = typeof job.stage === "string" ? job.stage.trim() : ""; + if (!id || !SOCIAL_MEDIA_QUEUE_STAGES.includes(stage as (typeof SOCIAL_MEDIA_QUEUE_STAGES)[number])) { + return null; + } + const runId = typeof job.run_id === "string" && UUID_PATTERN.test(job.run_id.trim()) + ? job.run_id.trim() + : null; + return { + id, + runId, + stage: stage as "media_mirror" | "comment_media_mirror", + status: typeof job.status === "string" && job.status.trim() + ? job.status.trim().toLowerCase() + : "queued", + accountHandle: typeof job.account_handle === "string" && job.account_handle.trim() + ? job.account_handle.trim().replace(/^@/, "") + : null, + sourceId: typeof job.source_id === "string" && job.source_id.trim() + ? job.source_id.trim() + : null, + queuedAgeSeconds: toNonNegativeInt(job.queued_age_seconds), + stale: Boolean(job.stale), + createdAt: typeof job.created_at === "string" && job.created_at.trim() + ? job.created_at.trim() + : null, + }; + }) + .filter((job): job is NonNullable => Boolean(job)) + .sort((left, right) => right.queuedAgeSeconds - left.queuedAgeSeconds); + +const buildMediaQueueBadgeSummary = ( + payload: SocialQueueStatusPayload | null | undefined, +): SocialMediaQueueBadgeSummary | null => { + const queue = payload?.queue; + if (!queue) return null; + const staleByStage = queue.media_stale_claims?.by_stage ?? {}; + const stages = SOCIAL_MEDIA_QUEUE_STAGES.map((stage) => { + const stagePlatformBucket = queue.by_stage_platform?.[stage]?.instagram; + const stageBucket = queue.by_stage?.[stage]; + const bucket = stagePlatformBucket ?? stageBucket; + const queued = queueBucketCount(bucket, "queued"); + const pending = queueBucketCount(bucket, "pending"); + const running = queueBucketCount(bucket, "running"); + const retrying = queueBucketCount(bucket, "retrying"); + const stale = toNonNegativeInt(staleByStage[stage]); + return { + key: stage, + label: SOCIAL_MEDIA_QUEUE_STAGE_LABELS[stage], + queued, + pending, + running, + retrying, + stale, + active: queued + pending + running + retrying, + }; + }); + const mediaCounts = SOCIAL_MEDIA_QUEUE_STAGES.reduce( + (acc, stage) => { + const stagePlatformBucket = queue.by_stage_platform?.[stage]?.instagram; + const stageBucket = queue.by_stage?.[stage]; + const bucket = stagePlatformBucket ?? stageBucket; + acc.queued += queueBucketCount(bucket, "queued"); + acc.pending += queueBucketCount(bucket, "pending"); + acc.running += queueBucketCount(bucket, "running"); + acc.retrying += queueBucketCount(bucket, "retrying"); + return acc; + }, + { queued: 0, pending: 0, running: 0, retrying: 0 }, + ); + const stale = toNonNegativeInt(queue.media_stale_claims?.total); + const staleAfterSecondsValue = queue.media_stale_claims?.stale_after_seconds; + const staleAfterSeconds = + staleAfterSecondsValue === null || staleAfterSecondsValue === undefined + ? null + : toNonNegativeInt(staleAfterSecondsValue); + const queuedJobsStaleAfterSecondsValue = queue.media_queued_jobs_stale_after_seconds; + const queuedJobsStaleAfterSeconds = + queuedJobsStaleAfterSecondsValue === null || queuedJobsStaleAfterSecondsValue === undefined + ? null + : toNonNegativeInt(queuedJobsStaleAfterSecondsValue); + return { + ...mediaCounts, + stale, + active: + mediaCounts.queued + + mediaCounts.pending + + mediaCounts.running + + mediaCounts.retrying, + staleAfterSeconds, + degraded: Boolean(payload?.degraded || queue.error), + stages, + runs: buildMediaQueueRunOptions(queue.media_runs), + queuedJobs: buildMediaQueueQueuedJobs(queue.media_queued_jobs), + queuedJobsStaleAfterSeconds, + }; +}; + const getMetadataString = ( metadata: Record | null | undefined, keys: readonly string[], @@ -261,6 +559,23 @@ const formatCompactTimestamp = (value: string | null | undefined): string | null }).format(date); }; +const formatQueueAge = (seconds: number): string => { + const safeSeconds = Math.max(0, Math.floor(seconds)); + if (safeSeconds >= 86_400) { + const days = Math.floor(safeSeconds / 86_400); + return `${days.toLocaleString()}d`; + } + if (safeSeconds >= 3_600) { + const hours = Math.floor(safeSeconds / 3_600); + return `${hours.toLocaleString()}h`; + } + if (safeSeconds >= 60) { + const minutes = Math.floor(safeSeconds / 60); + return `${minutes.toLocaleString()}m`; + } + return `${safeSeconds.toLocaleString()}s`; +}; + const formatInventoryCount = (count: number, total: number): string => `${count.toLocaleString()} / ${total.toLocaleString()}`; @@ -683,6 +998,49 @@ const ScrapeJobHealthBadge = ({ ); }; +const SocialMediaQueueBadge = ({ + summary, +}: { + summary: SocialMediaQueueBadgeSummary | null; +}) => { + if (!summary) return null; + const hasStale = summary.stale > 0; + const hasActive = summary.active > 0; + const Icon = hasStale || summary.degraded ? AlertTriangle : CircleCheck; + const toneClassName = + hasStale || summary.degraded + ? "border-amber-300 bg-amber-50 text-amber-900" + : hasActive + ? "border-sky-200 bg-sky-50 text-sky-800" + : "border-emerald-200 bg-emerald-50 text-emerald-800"; + const detail = hasStale + ? `${summary.stale.toLocaleString()} stale · ${summary.active.toLocaleString()} active` + : hasActive + ? `${summary.active.toLocaleString()} active · ${summary.running.toLocaleString()} running` + : "no active media jobs"; + const staleWindow = summary.staleAfterSeconds + ? `${Math.round(summary.staleAfterSeconds / 60).toLocaleString()} min` + : "configured window"; + + return ( + + + + + + + Instagram media mirror and comment media mirror queue depth. Stale means + a running media job has missed the {staleWindow} heartbeat window. + + + ); +}; + const SocialProgressStatusBadge = ({ status, }: { @@ -721,6 +1079,372 @@ const SocialProgressStatusBadge = ({ ); }; +const MediaQueueDrilldownPanel = ({ + summary, + runId, + stage, + drainAccountHandle, + recovering, + draining, + recoveryMessage, + recoveryError, + recoveryHistory, + drainMessage, + drainError, + drainHistory, + snapshotLinks, + onRunIdChange, + onStageChange, + onDrainAccountHandleChange, + onRecover, + onDrain, +}: { + summary: SocialMediaQueueBadgeSummary | null; + runId: string; + stage: "media_mirror" | "comment_media_mirror" | "all"; + drainAccountHandle: string; + recovering: boolean; + draining: boolean; + recoveryMessage: string | null; + recoveryError: string | null; + recoveryHistory: readonly SocialMediaQueueRecoveryHistoryRow[]; + drainMessage: string | null; + drainError: string | null; + drainHistory: readonly SocialMediaQueueDrainHistoryRow[]; + snapshotLinks: readonly SocialMediaQueueSnapshotLink[]; + onRunIdChange: (nextValue: string) => void; + onStageChange: (nextValue: "media_mirror" | "comment_media_mirror" | "all") => void; + onDrainAccountHandleChange: (nextValue: string) => void; + onRecover: () => void; + onDrain: () => void; +}) => { + const runIdValid = UUID_PATTERN.test(runId.trim()); + const selectedRunId = summary?.runs.some((run) => run.runId === runId.trim()) + ? runId.trim() + : ""; + const rows = + summary?.stages?.length + ? summary.stages + : SOCIAL_MEDIA_QUEUE_STAGES.map((key) => ({ + key, + label: SOCIAL_MEDIA_QUEUE_STAGE_LABELS[key], + queued: 0, + pending: 0, + running: 0, + retrying: 0, + stale: 0, + active: 0, + })); + const queuedJobs = summary?.queuedJobs ?? []; + + return ( +
+
+
+

Media Queue

+

+ Mirror recovery +

+
+
+ + onRunIdChange(event.target.value)} + placeholder="Run ID" + aria-label="Media recovery run ID" + className="min-h-10 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20" + /> + + + onDrainAccountHandleChange(event.target.value)} + placeholder="Account" + aria-label="Drain media account" + className="min-h-10 rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20" + /> + +
+
+ +
+ {rows.map((row) => { + const hasStale = row.stale > 0; + return ( +
+
+

{row.label}

+ + {row.stale.toLocaleString()} stale + +
+
+ {[ + ["Queued", row.queued], + ["Retrying", row.retrying], + ["Running", row.running], + ["Active", row.active], + ].map(([label, value]) => ( +
+

+ {Number(value).toLocaleString()} +

+

{label}

+
+ ))} +
+
+ ); + })} +
+ +
+
+

Oldest queued media jobs

+ + {summary?.queuedJobsStaleAfterSeconds + ? `stale after ${formatQueueAge(summary.queuedJobsStaleAfterSeconds)}` + : "oldest first"} + +
+ {queuedJobs.length ? ( +
+ {queuedJobs.slice(0, 6).map((job) => ( +
+
+

+ {job.sourceId || job.id} +

+

+ {SOCIAL_MEDIA_QUEUE_STAGE_LABELS[job.stage]} · {job.status} + {job.accountHandle ? ` · @${job.accountHandle}` : ""} + {job.runId ? ` · ${job.runId.slice(0, 8)}` : ""} +

+
+ + {formatQueueAge(job.queuedAgeSeconds)} + +
+ ))} +
+ ) : ( +

+ No queued media jobs are visible in the current snapshot. +

+ )} +
+ + {recoveryMessage ? ( +

+ {recoveryMessage} +

+ ) : null} + {recoveryError ? ( +

{recoveryError}

+ ) : null} + {drainMessage ? ( +

+ {drainMessage} +

+ ) : null} + {drainError ? ( +

{drainError}

+ ) : null} + +
+
+
+

Recovery history

+ + {recoveryHistory.length.toLocaleString()} row{recoveryHistory.length === 1 ? "" : "s"} + +
+ {recoveryHistory.length > 0 ? ( +
+ {recoveryHistory.map((row) => ( +
+
+

+ {row.runId} +

+

+ {row.stage === "all" + ? "All media" + : SOCIAL_MEDIA_QUEUE_STAGE_LABELS[row.stage]} · {formatCompactTimestamp(row.recoveredAt) || row.recoveredAt} +

+
+

+ {row.recoveredCount.toLocaleString()} recovered · {row.dispatchedCount.toLocaleString()} dispatched +

+

+ {row.resultSummary} +

+
+ ))} +
+ ) : ( +

+ Recoveries made from this panel will appear here. +

+ )} +
+ +
+
+

Drain history

+ + {drainHistory.length.toLocaleString()} row{drainHistory.length === 1 ? "" : "s"} + +
+ {drainHistory.length > 0 ? ( +
+ {drainHistory.map((row) => ( +
+
+

+ @{row.accountHandle} · {row.runId} +

+

+ {row.stage === "all" + ? "All media" + : SOCIAL_MEDIA_QUEUE_STAGE_LABELS[row.stage]} · {formatCompactTimestamp(row.drainedAt) || row.drainedAt} +

+
+
+ {[ + ["Before", row.beforeRemaining], + ["Recovered", row.recoveredCount], + ["Dispatched", row.dispatchedCount], + ["After", row.afterRemaining], + ].map(([label, value]) => ( +
+

+ {Number(value).toLocaleString()} +

+

{label}

+
+ ))} +
+

+ Stop: {row.stopReason || "none"} · Next: {row.nextRecommendedAction || "none"} +

+
+ ))} +
+ ) : ( +

+ Drain results made from this panel will appear here. +

+ )} +
+ +
+
+

Queue snapshots

+ + {snapshotLinks.length.toLocaleString()} link{snapshotLinks.length === 1 ? "" : "s"} + +
+ {snapshotLinks.length > 0 ? ( + + ) : ( +

+ No saved queue snapshot logs are available yet. +

+ )} +
+
+
+ ); +}; + const SharedSourceFallbackWarning = ({ statuses, reloading, @@ -1052,6 +1776,55 @@ const loadLandingData = async ( }; }; +const loadMediaQueueSummary = async ( + currentUser: User, +): Promise => { + const startedAt = nowMs(); + const response = await fetchAdminWithAuth( + "/api/admin/trr-api/social/ingest/queue-status?fresh=true&detail=summary", + undefined, + { + allowDevAdminBypass: true, + preferredUser: currentUser, + }, + ); + recordAdminLoadSample({ + surface: "admin-social-landing-api", + path: "/admin/social", + source: "api", + duration_ms: nowMs() - startedAt, + cache_status: response.headers.get("x-trr-cache"), + server_timing: response.headers.get("server-timing"), + }); + const data = (await response.json().catch(() => ({}))) as + | ({ error?: string } & SocialQueueStatusPayload) + | undefined; + if (!response.ok) { + throw new Error(data?.error || "Failed to load social media queue status"); + } + return buildMediaQueueBadgeSummary(data); +}; + +const loadMediaQueueSnapshotLinks = async ( + currentUser: User, +): Promise => { + const response = await fetchAdminWithAuth( + "/api/admin/social/media-queue/snapshots", + undefined, + { + allowDevAdminBypass: true, + preferredUser: currentUser, + }, + ); + const data = (await response.json().catch(() => ({}))) as + | { snapshots?: SocialMediaQueueSnapshotLink[]; error?: string } + | undefined; + if (!response.ok) { + throw new Error(data?.error || "Failed to load media queue snapshots"); + } + return Array.isArray(data?.snapshots) ? data.snapshots : []; +}; + const readCachedLandingData = (): CachedLandingData | null => { if (typeof window === "undefined") return null; try { @@ -2095,12 +2868,36 @@ const NetworkSourceGroupCard = ({ export default function AdminSocialMediaPage() { const { user, checking, hasAccess } = useAdminGuard(); + const appFlags = getTrrAppFlags(); + const adminSocialIngestionUiEnabled = appFlags.adminSocialIngestionUi; + const adminSocialScraperTriggersEnabled = appFlags.adminSocialScraperTriggers; const [landing, setLanding] = useState(null); const landingLoadStartedAtRef = useRef(nowMs()); const landingNavigationSampleRecordedRef = useRef(false); const [landingFreshnessAt, setLandingFreshnessAt] = useState(null); const [loadingLanding, setLoadingLanding] = useState(false); const [loadError, setLoadError] = useState(null); + const [mediaQueueSummary, setMediaQueueSummary] = + useState(null); + const [mediaRecoveryRunId, setMediaRecoveryRunId] = useState(""); + const [mediaRecoveryStage, setMediaRecoveryStage] = + useState<"media_mirror" | "comment_media_mirror" | "all">("media_mirror"); + const [mediaDrainAccountHandle, setMediaDrainAccountHandle] = useState("bravotv"); + const [recoveringMediaQueue, setRecoveringMediaQueue] = useState(false); + const [drainingMediaQueue, setDrainingMediaQueue] = useState(false); + const [mediaRecoveryMessage, setMediaRecoveryMessage] = useState(null); + const [mediaRecoveryError, setMediaRecoveryError] = useState(null); + const [mediaRecoveryHistory, setMediaRecoveryHistory] = useState< + SocialMediaQueueRecoveryHistoryRow[] + >([]); + const [mediaDrainMessage, setMediaDrainMessage] = useState(null); + const [mediaDrainError, setMediaDrainError] = useState(null); + const [mediaDrainHistory, setMediaDrainHistory] = useState< + SocialMediaQueueDrainHistoryRow[] + >([]); + const [mediaQueueSnapshotLinks, setMediaQueueSnapshotLinks] = useState< + SocialMediaQueueSnapshotLink[] + >([]); const [reloadingSharedSources, setReloadingSharedSources] = useState(false); const [sharedSourceReloadMessage, setSharedSourceReloadMessage] = useState(null); const [addHandleDialogOpen, setAddHandleDialogOpen] = useState(false); @@ -2173,6 +2970,260 @@ export default function AdminSocialMediaPage() { }; }, [checking, hasAccess, user]); + const recoverStaleMediaQueueJobs = async () => { + if (!user || !adminSocialScraperTriggersEnabled) return; + const runId = mediaRecoveryRunId.trim(); + if (!UUID_PATTERN.test(runId)) { + setMediaRecoveryError("Enter a valid run ID."); + return; + } + + setRecoveringMediaQueue(true); + setMediaRecoveryError(null); + setMediaRecoveryMessage(null); + try { + const response = await fetchAdminWithAuth( + "/api/admin/trr-api/social/ingest/media-mirror/recover-stale", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + run_id: runId, + stage: mediaRecoveryStage, + stale_after_seconds: 900, + recover_limit: 5, + dispatch_limit: 8, + confirm_recovery: SOCIAL_MEDIA_RECOVERY_CONFIRMATION, + }), + }, + { allowDevAdminBypass: true, preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as + | { + error?: string; + recovered_count?: number; + recovered_by_stage?: Record; + dispatch?: { dispatched_job_ids?: unknown[] }; + } + | undefined; + if (!response.ok) { + throw new Error(data?.error || "Failed to recover stale media jobs"); + } + const dispatchedCount = Array.isArray(data?.dispatch?.dispatched_job_ids) + ? data.dispatch.dispatched_job_ids.length + : 0; + const recoveredCount = toNonNegativeInt(data?.recovered_count); + const recoveredByStage = data?.recovered_by_stage ?? {}; + const stageSummary = SOCIAL_MEDIA_QUEUE_STAGES.map((stageKey) => { + const stageRows = recoveredByStage[stageKey]; + const count = Array.isArray(stageRows) ? stageRows.length : 0; + return count > 0 + ? `${SOCIAL_MEDIA_QUEUE_STAGE_LABELS[stageKey]} ${count.toLocaleString()}` + : null; + }).filter((item): item is string => Boolean(item)); + const resultSummary = [ + stageSummary.length ? stageSummary.join(", ") : "No stale claims recovered", + `${dispatchedCount.toLocaleString()} dispatch follow-up${dispatchedCount === 1 ? "" : "s"}`, + ].join("; "); + setMediaRecoveryMessage( + `Recovered ${recoveredCount.toLocaleString()} stale media job${ + recoveredCount === 1 ? "" : "s" + }; dispatched ${dispatchedCount.toLocaleString()}.`, + ); + setMediaRecoveryHistory((current) => [ + { + id: `${Date.now()}:${runId}:${mediaRecoveryStage}`, + runId, + stage: mediaRecoveryStage, + recoveredCount, + dispatchedCount, + resultSummary, + recoveredAt: new Date().toISOString(), + }, + ...current, + ].slice(0, 10)); + const nextSummary = await loadMediaQueueSummary(user); + setMediaQueueSummary(nextSummary); + } catch (error) { + setMediaRecoveryError( + error instanceof Error ? error.message : "Failed to recover stale media jobs", + ); + } finally { + setRecoveringMediaQueue(false); + } + }; + + const drainBravoMediaQueueJobs = async () => { + if (!user || !adminSocialScraperTriggersEnabled) return; + const runId = mediaRecoveryRunId.trim(); + const accountHandle = mediaDrainAccountHandle.trim().replace(/^@/, "") || "bravotv"; + if (!UUID_PATTERN.test(runId)) { + setMediaDrainError("Enter a valid run ID."); + return; + } + + setDrainingMediaQueue(true); + setMediaDrainError(null); + setMediaDrainMessage(null); + try { + const response = await fetchAdminWithAuth( + "/api/admin/trr-api/social/ingest/media-mirror/drain-account", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + run_id: runId, + account_handle: accountHandle, + stage: mediaRecoveryStage, + recover_limit: 25, + dispatch_limit: 8, + confirm_drain: SOCIAL_MEDIA_DRAIN_CONFIRMATION, + }), + }, + { allowDevAdminBypass: true, preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as + | ({ error?: string; dispatch?: { dispatched_job_ids?: unknown[] } } & Record) + | undefined; + if (!response.ok) { + throw new Error(data?.error || "Failed to drain Bravo media jobs"); + } + + const nestedDrain = + data?.drain && typeof data.drain === "object" + ? (data.drain as Record) + : data?.status && typeof data.status === "object" + ? (data.status as Record) + : data; + const dispatchedCount = Array.isArray(data?.dispatch?.dispatched_job_ids) + ? data.dispatch.dispatched_job_ids.length + : getFirstNonNegativeInt(nestedDrain, [ + "dispatched", + "dispatched_count", + "dispatched_job_count", + ]); + const beforeRemaining = getFirstNonNegativeInt(nestedDrain, [ + "before_remaining", + "before_remaining_count", + "remaining_before", + ]); + const recoveredCount = getFirstNonNegativeInt(nestedDrain, [ + "recovered", + "recovered_count", + "recovered_job_count", + ]); + const afterRemaining = getFirstNonNegativeInt(nestedDrain, [ + "after_remaining", + "after_remaining_count", + "remaining_after", + ]); + const stopReason = getFirstString(nestedDrain, ["stop_reason", "reason"]); + const nextRecommendedAction = getFirstString(nestedDrain, [ + "next_recommended_action", + "next_action", + "recommended_action", + ]); + + setMediaDrainMessage( + `Drained @${accountHandle}: ${beforeRemaining.toLocaleString()} before, ${recoveredCount.toLocaleString()} recovered, ${dispatchedCount.toLocaleString()} dispatched, ${afterRemaining.toLocaleString()} after.`, + ); + setMediaDrainHistory((current) => [ + { + id: `${Date.now()}:${runId}:${accountHandle}:${mediaRecoveryStage}`, + runId, + accountHandle, + stage: mediaRecoveryStage, + beforeRemaining, + recoveredCount, + dispatchedCount, + afterRemaining, + stopReason, + nextRecommendedAction, + drainedAt: new Date().toISOString(), + }, + ...current, + ].slice(0, 10)); + const nextSummary = await loadMediaQueueSummary(user); + setMediaQueueSummary(nextSummary); + } catch (error) { + setMediaDrainError( + error instanceof Error ? error.message : "Failed to drain Bravo media jobs", + ); + } finally { + setDrainingMediaQueue(false); + } + }; + + useEffect(() => { + if (!adminSocialIngestionUiEnabled) { + setMediaRecoveryRunId(""); + return; + } + const firstRunId = mediaQueueSummary?.runs[0]?.runId; + if (!mediaRecoveryRunId && firstRunId) { + setMediaRecoveryRunId(firstRunId); + } + }, [adminSocialIngestionUiEnabled, mediaQueueSummary, mediaRecoveryRunId]); + + useEffect(() => { + if (checking || !user || !hasAccess || !adminSocialIngestionUiEnabled) { + setMediaQueueSummary(null); + setMediaQueueSnapshotLinks([]); + return; + } + + let cancelled = false; + const load = async () => { + try { + const summary = await loadMediaQueueSummary(user); + if (!cancelled) { + setMediaQueueSummary(summary); + } + } catch { + if (!cancelled) { + setMediaQueueSummary({ + queued: 0, + pending: 0, + running: 0, + retrying: 0, + stale: 0, + active: 0, + staleAfterSeconds: null, + degraded: true, + stages: SOCIAL_MEDIA_QUEUE_STAGES.map((key) => ({ + key, + label: SOCIAL_MEDIA_QUEUE_STAGE_LABELS[key], + queued: 0, + pending: 0, + running: 0, + retrying: 0, + stale: 0, + active: 0, + })), + runs: [], + queuedJobs: [], + queuedJobsStaleAfterSeconds: null, + }); + } + } + try { + const snapshots = await loadMediaQueueSnapshotLinks(user); + if (!cancelled) { + setMediaQueueSnapshotLinks(snapshots); + } + } catch { + if (!cancelled) { + setMediaQueueSnapshotLinks([]); + } + } + }; + + void load(); + return () => { + cancelled = true; + }; + }, [adminSocialIngestionUiEnabled, checking, hasAccess, user]); + const saveShowHandleOverrides = async ( show: ShowProfileSet, draft: ShowHandleDraft, @@ -2483,6 +3534,9 @@ export default function AdminSocialMediaPage() { {landing ? (
+ {adminSocialIngestionUiEnabled ? ( + + ) : null}
) : null} @@ -2554,6 +3608,49 @@ export default function AdminSocialMediaPage() { }} /> + {adminSocialIngestionUiEnabled ? ( + { + setMediaRecoveryRunId(nextValue); + setMediaRecoveryError(null); + setMediaRecoveryMessage(null); + setMediaDrainError(null); + setMediaDrainMessage(null); + }} + onStageChange={(nextValue) => { + setMediaRecoveryStage(nextValue); + setMediaRecoveryError(null); + setMediaRecoveryMessage(null); + setMediaDrainError(null); + setMediaDrainMessage(null); + }} + onDrainAccountHandleChange={(nextValue) => { + setMediaDrainAccountHandle(nextValue); + setMediaDrainError(null); + setMediaDrainMessage(null); + }} + onRecover={() => { + void recoverStaleMediaQueueJobs(); + }} + onDrain={() => { + void drainBravoMediaQueueJobs(); + }} + /> + ) : null} +
diff --git a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]/page.tsx b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]/page.tsx index deb548b9..1b2d564c 100644 --- a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]/page.tsx +++ b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]/page.tsx @@ -1 +1,22 @@ -export { default } from "@/app/admin/reddit-post-details/page"; +import { redirect } from "next/navigation"; + +type AdminRedditSeasonDetailAliasPageProps = { + params: Promise<{ + communitySlug: string; + showSlug: string; + seasonNumber: string; + windowKey: string; + detailSlug: string; + }>; +}; + +export default async function AdminRedditSeasonDetailAliasPage({ + params, +}: AdminRedditSeasonDetailAliasPageProps) { + const { communitySlug, showSlug, seasonNumber, windowKey, detailSlug } = await params; + redirect( + `/${encodeURIComponent(showSlug)}/social/reddit/${encodeURIComponent(communitySlug)}/${encodeURIComponent( + seasonNumber, + )}/${encodeURIComponent(windowKey)}/${encodeURIComponent(detailSlug)}`, + ); +} diff --git a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/page.tsx b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/page.tsx index 12d3a292..3a8fe45a 100644 --- a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/page.tsx +++ b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/page.tsx @@ -1 +1,16 @@ -export { default } from "@/app/admin/reddit-window-posts/page"; +import { redirect } from "next/navigation"; + +type AdminRedditSeasonWindowAliasPageProps = { + params: Promise<{ communitySlug: string; showSlug: string; seasonNumber: string; windowKey: string }>; +}; + +export default async function AdminRedditSeasonWindowAliasPage({ + params, +}: AdminRedditSeasonWindowAliasPageProps) { + const { communitySlug, showSlug, seasonNumber, windowKey } = await params; + redirect( + `/${encodeURIComponent(showSlug)}/social/reddit/${encodeURIComponent(communitySlug)}/${encodeURIComponent( + seasonNumber, + )}/${encodeURIComponent(windowKey)}`, + ); +} diff --git a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]/page.tsx b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]/page.tsx index deb548b9..881fe729 100644 --- a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]/page.tsx +++ b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]/page.tsx @@ -1 +1,20 @@ -export { default } from "@/app/admin/reddit-post-details/page"; +import { redirect } from "next/navigation"; + +type AdminRedditSeasonPostAliasPageProps = { + params: Promise<{ + communitySlug: string; + showSlug: string; + seasonNumber: string; + windowKey: string; + postId: string; + }>; +}; + +export default async function AdminRedditSeasonPostAliasPage({ params }: AdminRedditSeasonPostAliasPageProps) { + const { communitySlug, showSlug, seasonNumber, windowKey, postId } = await params; + redirect( + `/${encodeURIComponent(showSlug)}/social/reddit/${encodeURIComponent(communitySlug)}/${encodeURIComponent( + seasonNumber, + )}/${encodeURIComponent(windowKey)}/${encodeURIComponent(postId)}`, + ); +} diff --git a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/page.tsx b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/page.tsx index 4132f91f..7dbfb8c9 100644 --- a/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/page.tsx +++ b/apps/web/src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/page.tsx @@ -1 +1,14 @@ -export { default } from "@/components/admin/RedditCommunityViewPage"; +import { redirect } from "next/navigation"; + +type AdminRedditSeasonAliasPageProps = { + params: Promise<{ communitySlug: string; showSlug: string; seasonNumber: string }>; +}; + +export default async function AdminRedditSeasonAliasPage({ params }: AdminRedditSeasonAliasPageProps) { + const { communitySlug, showSlug, seasonNumber } = await params; + redirect( + `/${encodeURIComponent(showSlug)}/social/reddit/${encodeURIComponent(communitySlug)}/${encodeURIComponent( + seasonNumber, + )}`, + ); +} diff --git a/apps/web/src/app/admin/trr-shows/[showId]/page.tsx b/apps/web/src/app/admin/trr-shows/[showId]/page.tsx index 4a5d1de8..8d682f49 100644 --- a/apps/web/src/app/admin/trr-shows/[showId]/page.tsx +++ b/apps/web/src/app/admin/trr-shows/[showId]/page.tsx @@ -2818,6 +2818,7 @@ export default function TrrShowDetailPage() { const [refreshLogEntries, setRefreshLogEntries] = useState([]); const [refreshLogOpen, setRefreshLogOpen] = useState(false); const [autoShowCorePaused, setAutoShowCorePaused] = useState(false); + const [autoShowCorePauseLoaded, setAutoShowCorePauseLoaded] = useState(false); const [autoShowCorePauseSaving, setAutoShowCorePauseSaving] = useState(false); const [autoShowCorePauseError, setAutoShowCorePauseError] = useState(null); const healthCenterScrollTopRef = useRef(0); @@ -10516,15 +10517,20 @@ export default function TrrShowDetailPage() { ? episodeIdIgnoredSeasonZeroWarning.count : null); - const preNavigationShowCoreOperationId = useMemo(() => { - const value = searchParams.get("showCoreOperationId"); + const preNavigationShowRefreshOperationId = useMemo(() => { + const value = searchParams.get("showRefreshOperationId") ?? searchParams.get("showCoreOperationId"); return value && value.trim().length > 0 ? value.trim() : null; }, [searchParams]); + const preNavigationShowRefreshStarted = useMemo( + () => preNavigationShowRefreshOperationId !== null || searchParams.get("showRefreshStarted") === "1", + [preNavigationShowRefreshOperationId, searchParams], + ); useEffect(() => { let cancelled = false; const loadAutoShowCoreSettings = async () => { try { + setAutoShowCorePauseLoaded(false); setAutoShowCorePauseError(null); const headers = await getAuthHeaders(); const response = await fetch(`/api/admin/trr-api/shows/settings/show-core-auto-refresh`, { @@ -10544,6 +10550,10 @@ export default function TrrShowDetailPage() { err instanceof Error ? err.message : "Failed to load auto-refresh setting", ); } + } finally { + if (!cancelled) { + setAutoShowCorePauseLoaded(true); + } } }; void loadAutoShowCoreSettings(); @@ -10579,42 +10589,6 @@ export default function TrrShowDetailPage() { })(); }, [autoShowCorePaused, getAuthHeaders]); - useEffect(() => { - if (!showId || !episodeIdGapWarning) return; - if (autoShowCorePaused) return; - if (refreshingShowAll || refreshingTargets.show_core) return; - - const warningCount = - typeof episodeIdGapWarning.count === "number" && Number.isFinite(episodeIdGapWarning.count) - ? episodeIdGapWarning.count - : 0; - const storageKey = `trr:show-core-auto-refresh:${showId}:${warningCount}`; - try { - if (window.sessionStorage.getItem(storageKey) === "1") return; - window.sessionStorage.setItem(storageKey, "1"); - } catch { - // Session storage is best-effort; a blocked storage API should not block repair. - } - - appendRefreshLog({ - category: "Show Core", - message: "Episode ID gaps detected; starting show core refresh.", - current: null, - total: null, - topic: "show_core", - }); - setRefreshLogOpen(true); - void refreshShow("show_core"); - }, [ - appendRefreshLog, - episodeIdGapWarning, - autoShowCorePaused, - refreshShow, - refreshingShowAll, - refreshingTargets.show_core, - showId, - ]); - const retryRefreshTarget = useCallback( async (target: string, parentOperationId: string) => { try { @@ -10915,6 +10889,32 @@ export default function TrrShowDetailPage() { showId, ]); + useEffect(() => { + if (!showId) return; + if (preNavigationShowRefreshStarted) return; + if (!autoShowCorePauseLoaded) return; + if (autoShowCorePaused) return; + if (refreshingShowAll || Object.values(refreshingTargets).some(Boolean)) return; + + const storageKey = `trr:show-full-auto-refresh:${showId}`; + try { + if (window.sessionStorage.getItem(storageKey) === "1") return; + window.sessionStorage.setItem(storageKey, "1"); + } catch { + // Session storage is best-effort; a blocked storage API should not block refresh. + } + + void refreshAllShowData(); + }, [ + autoShowCorePaused, + autoShowCorePauseLoaded, + preNavigationShowRefreshStarted, + refreshAllShowData, + refreshingShowAll, + refreshingTargets, + showId, + ]); + const captureHealthCenterScrollPosition = useCallback(() => { if (typeof document === "undefined") return; const panel = document.querySelector('[role="dialog"][aria-label="Health Center"]'); @@ -12533,13 +12533,15 @@ export default function TrrShowDetailPage() {
)}
- {preNavigationShowCoreOperationId && ( + {preNavigationShowRefreshStarted && (
-

Show core refresh started before navigation.

-

- Active operation ID:{" "} - {preNavigationShowCoreOperationId} -

+

Show refresh started before navigation.

+ {preNavigationShowRefreshOperationId && ( +

+ Active operation ID:{" "} + {preNavigationShowRefreshOperationId} +

+ )}
)} {(episodeIdGapWarning || ignoredSeasonZeroCount !== null) && ( @@ -12570,7 +12572,7 @@ export default function TrrShowDetailPage() { ? ` Ignored season 0 specials: ${ignoredSeasonZeroCount.toLocaleString()}.` : ""} {autoShowCorePaused && episodeIdGapWarning - ? " Automatic show core refreshes are paused." + ? " Automatic show refreshes are paused." : ""} {autoShowCorePauseError ? ` ${autoShowCorePauseError}` : ""}

diff --git a/apps/web/src/app/admin/trr-shows/page.tsx b/apps/web/src/app/admin/trr-shows/page.tsx index 70dc2b0a..1cd7fc9f 100644 --- a/apps/web/src/app/admin/trr-shows/page.tsx +++ b/apps/web/src/app/admin/trr-shows/page.tsx @@ -175,8 +175,11 @@ type PreNavigationShowCoreState = { showId: string; operationId: string | null; paused: boolean; + started: boolean; }; +const AUTO_SHOW_REFRESH_TARGETS = ["show_core", "links", "bravo", "cast_profiles", "cast_media"] as const; + export default function TrrShowsPage() { const router = useRouter(); const { user, userKey, checking, hasAccess } = useAdminGuard(); @@ -192,7 +195,7 @@ export default function TrrShowsPage() { const [syncingLists, setSyncingLists] = useState(false); const [syncListsNotice, setSyncListsNotice] = useState(null); const [syncListsError, setSyncListsError] = useState(null); - const [preNavigationShowCore, setPreNavigationShowCore] = useState(null); + const [preNavigationShowRefresh, setPreNavigationShowRefresh] = useState(null); // Covered shows state const [coveredShows, setCoveredShows] = useState([]); @@ -211,14 +214,17 @@ export default function TrrShowsPage() { [user], ); - const enqueueShowCoreBeforeNavigation = useCallback( - async (showId: string): Promise<{ operationId: string | null; paused: boolean }> => { + const enqueueShowAutoRefresh = useCallback( + async ( + showId: string, + source: "admin_show_list_add" | "admin_show_list_pre_navigation", + ): Promise<{ operationId: string | null; paused: boolean; started: boolean }> => { const settingsResponse = await fetchWithAuth( "/api/admin/trr-api/shows/settings/show-core-auto-refresh", ); const settings = (await settingsResponse.json().catch(() => ({}))) as { paused?: unknown }; if (settingsResponse.ok && settings.paused === true) { - return { operationId: null, paused: true }; + return { operationId: null, paused: true, started: false }; } const controller = new AbortController(); @@ -236,10 +242,10 @@ export default function TrrShowsPage() { "Content-Type": "application/json", }, body: JSON.stringify({ - targets: ["show_core"], + targets: AUTO_SHOW_REFRESH_TARGETS, force_new_operation: true, auto_refresh: true, - source: "admin_show_list_pre_navigation", + source, }), timeoutMs: 3_000, externalSignal: controller.signal, @@ -258,10 +264,10 @@ export default function TrrShowsPage() { }); } catch (err) { if (!sawOperationStart) { - console.warn("Failed to enqueue show core before navigation:", err); + console.warn("Failed to enqueue show auto-refresh:", err); } } - return { operationId, paused: false }; + return { operationId, paused: false, started: sawOperationStart }; }, [fetchWithAuth, user], ); @@ -271,24 +277,34 @@ export default function TrrShowsPage() { if (shouldUseNormalLinkNavigation(event)) return; event.preventDefault(); - setPreNavigationShowCore({ showId, operationId: null, paused: false }); - void enqueueShowCoreBeforeNavigation(showId) + setPreNavigationShowRefresh({ showId, operationId: null, paused: false, started: false }); + void enqueueShowAutoRefresh(showId, "admin_show_list_pre_navigation") .catch((err) => { - console.warn("Failed to check or start show core before navigation:", err); - return { operationId: null, paused: false }; + console.warn("Failed to check or start show auto-refresh before navigation:", err); + return { operationId: null, paused: false, started: false }; }) .then((result) => { - setPreNavigationShowCore({ showId, operationId: result.operationId, paused: result.paused }); - const targetHref = result.operationId - ? (`${href}${String(href).includes("?") ? "&" : "?"}showCoreOperationId=${encodeURIComponent(result.operationId)}` as Route) + setPreNavigationShowRefresh({ + showId, + operationId: result.operationId, + paused: result.paused, + started: result.started, + }); + const param = result.operationId + ? `showRefreshOperationId=${encodeURIComponent(result.operationId)}` + : result.started + ? "showRefreshStarted=1" + : null; + const targetHref = param + ? (`${href}${String(href).includes("?") ? "&" : "?"}${param}` as Route) : href; setTimeout(() => { - setPreNavigationShowCore((current) => (current?.showId === showId ? null : current)); + setPreNavigationShowRefresh((current) => (current?.showId === showId ? null : current)); router.push(targetHref); }, result.operationId || result.paused ? 650 : 0); }); }, - [enqueueShowCoreBeforeNavigation, router], + [enqueueShowAutoRefresh, router], ); // Fetch covered shows @@ -362,6 +378,17 @@ export default function TrrShowsPage() { } await fetchCoveredShows(); + setPreNavigationShowRefresh({ showId: show.id, operationId: null, paused: false, started: false }); + const autoRefresh = await enqueueShowAutoRefresh(show.id, "admin_show_list_add"); + setPreNavigationShowRefresh({ + showId: show.id, + operationId: autoRefresh.operationId, + paused: autoRefresh.paused, + started: autoRefresh.started, + }); + setTimeout(() => { + setPreNavigationShowRefresh((current) => (current?.showId === show.id ? null : current)); + }, autoRefresh.operationId || autoRefresh.paused ? 3_000 : 650); } catch (err) { console.error("Failed to add covered show:", err); setError(err instanceof Error ? err.message : "Failed to add show"); @@ -369,7 +396,7 @@ export default function TrrShowsPage() { setAddingShowId(null); } }, - [fetchCoveredShows, fetchWithAuth] + [enqueueShowAutoRefresh, fetchCoveredShows, fetchWithAuth] ); // Remove show from covered list @@ -661,13 +688,15 @@ export default function TrrShowsPage() { {metaText && (

{metaText}

)} - {preNavigationShowCore?.showId === show.id && ( + {preNavigationShowRefresh?.showId === show.id && (

- {preNavigationShowCore.paused - ? "Show core auto-refresh paused" - : preNavigationShowCore.operationId - ? `Show core op ${preNavigationShowCore.operationId.slice(0, 8)}` - : "Starting show core..."} + {preNavigationShowRefresh.paused + ? "Auto-refresh paused" + : preNavigationShowRefresh.operationId + ? `Refresh op ${preNavigationShowRefresh.operationId.slice(0, 8)}` + : preNavigationShowRefresh.started + ? "Refresh started" + : "Starting auto-refresh..."}

)} @@ -854,13 +883,15 @@ export default function TrrShowsPage() { ? totalEpisodes.toLocaleString() : "—"} - {preNavigationShowCore?.showId === show.trr_show_id && ( + {preNavigationShowRefresh?.showId === show.trr_show_id && ( - {preNavigationShowCore.paused - ? "Show core auto-refresh paused" - : preNavigationShowCore.operationId - ? `Show core op ${preNavigationShowCore.operationId.slice(0, 8)}` - : "Starting show core..."} + {preNavigationShowRefresh.paused + ? "Auto-refresh paused" + : preNavigationShowRefresh.operationId + ? `Refresh op ${preNavigationShowRefresh.operationId.slice(0, 8)}` + : preNavigationShowRefresh.started + ? "Refresh started" + : "Starting auto-refresh..."} )}
diff --git a/apps/web/src/app/api/admin/social/media-queue/snapshots/route.ts b/apps/web/src/app/api/admin/social/media-queue/snapshots/route.ts new file mode 100644 index 00000000..9f49b5e9 --- /dev/null +++ b/apps/web/src/app/api/admin/social/media-queue/snapshots/route.ts @@ -0,0 +1,92 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; + +export const dynamic = "force-dynamic"; + +const SNAPSHOT_FILE_PATTERN = /^[0-9TZ_.:-]+-[0-9a-f-]+-(?:media_mirror|comment_media_mirror|all)\.json$/i; + +const getSnapshotDirectory = (): string => + path.resolve(process.cwd(), "../../..", ".logs/workspace/social-queue-snapshots"); + +const safeSnapshotFileName = (value: string | null): string | null => { + const fileName = typeof value === "string" ? value.trim() : ""; + if (!fileName || path.basename(fileName) !== fileName) return null; + if (!SNAPSHOT_FILE_PATTERN.test(fileName)) return null; + return fileName; +}; + +const parseSnapshotFileName = ( + name: string, +): { createdAt: string | null; runId: string | null; stage: string | null } => { + const match = name.match( + /^([0-9TZ_.:-]+?)-([0-9a-f-]+)-(media_mirror|comment_media_mirror|all)\.json$/i, + ); + if (!match) { + return { createdAt: null, runId: null, stage: null }; + } + const compactTimestamp = match[1]; + const compactMatch = compactTimestamp.match( + /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/i, + ); + const createdAt = compactMatch + ? `${compactMatch[1]}-${compactMatch[2]}-${compactMatch[3]}T${compactMatch[4]}:${compactMatch[5]}:${compactMatch[6]}Z` + : compactTimestamp.replace(/_/g, ":").replace(/^(\d{4}-\d{2}-\d{2})T/, "$1T"); + return { + createdAt: createdAt.endsWith("Z") ? createdAt : `${createdAt}Z`, + runId: match[2], + stage: match[3], + }; +}; + +export async function GET(request: NextRequest) { + try { + await requireAdmin(request); + const fileParam = request.nextUrl.searchParams.get("file"); + const requestedFile = safeSnapshotFileName(fileParam); + if (fileParam !== null && !requestedFile) { + return NextResponse.json({ error: "Invalid snapshot file name" }, { status: 400 }); + } + const snapshotDir = getSnapshotDirectory(); + + if (requestedFile) { + const filePath = path.join(snapshotDir, requestedFile); + const content = await fs.readFile(filePath, "utf8"); + return new NextResponse(content, { + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + }); + } + + const entries = await fs.readdir(snapshotDir, { withFileTypes: true }).catch((error: unknown) => { + if ( + error && + typeof error === "object" && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ) { + return []; + } + throw error; + }); + const files = entries + .filter((entry) => entry.isFile() && safeSnapshotFileName(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left)) + .slice(0, 20); + + return NextResponse.json({ + snapshots: files.map((name) => ({ + name, + href: `/api/admin/social/media-queue/snapshots?file=${encodeURIComponent(name)}`, + ...parseSnapshotFileName(name), + })), + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load media queue snapshots"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social-growth/calls/[callId]/route.ts b/apps/web/src/app/api/admin/trr-api/social-growth/calls/[callId]/route.ts new file mode 100644 index 00000000..2d9b0fcb --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social-growth/calls/[callId]/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { getBackendApiUrl } from "@/lib/server/trr-api/backend"; +import { buildInternalAdminHeaders } from "@/lib/server/trr-api/internal-admin-auth"; +import { + isTimeoutSafeFetchTimeoutError, + timeoutSafeFetch, +} from "@/lib/server/timeout-safe-fetch"; +import { + buildSocialBladeBackendErrorPayload, + buildSocialBladeTimeoutResponse, +} from "@/lib/server/trr-api/socialblade-proxy"; + +export const dynamic = "force-dynamic"; + +const SOCIALBLADE_CALL_STATUS_TIMEOUT_MS = 15_000; + +interface RouteParams { + params: Promise<{ callId: string }>; +} + +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + await requireAdmin(request); + + const { callId } = await params; + const safeCallId = String(callId || "").trim(); + if (!safeCallId) { + return NextResponse.json({ error: "callId is required" }, { status: 400 }); + } + + const backendUrl = getBackendApiUrl( + `/admin/people/socialblade/calls/${encodeURIComponent(safeCallId)}` + ); + if (!backendUrl) { + return NextResponse.json( + { error: "Backend API not configured (TRR_API_URL)" }, + { status: 502 } + ); + } + + let headers: Headers; + try { + headers = buildInternalAdminHeaders(); + } catch { + return NextResponse.json({ error: "Backend auth not configured" }, { status: 502 }); + } + + const upstream = await timeoutSafeFetch(backendUrl, { + headers, + timeoutMs: SOCIALBLADE_CALL_STATUS_TIMEOUT_MS, + timeoutName: "socialblade-call-status", + }); + const data = await upstream.json().catch(() => ({ error: "Invalid response from backend" })); + + if (!upstream.ok) { + return NextResponse.json( + buildSocialBladeBackendErrorPayload(data, `Backend returned ${upstream.status}`), + { status: upstream.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + if (isTimeoutSafeFetchTimeoutError(error)) { + return buildSocialBladeTimeoutResponse(error, SOCIALBLADE_CALL_STATUS_TIMEOUT_MS); + } + console.error("[api] Failed to get SocialBlade call status", error); + const message = error instanceof Error ? error.message : "failed"; + const status = message === "unauthorized" ? 401 : message === "forbidden" ? 403 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social-growth/cookies/health/route.ts b/apps/web/src/app/api/admin/trr-api/social-growth/cookies/health/route.ts new file mode 100644 index 00000000..22c2e870 --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social-growth/cookies/health/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { getBackendApiUrl } from "@/lib/server/trr-api/backend"; +import { buildInternalAdminHeaders } from "@/lib/server/trr-api/internal-admin-auth"; +import { + isTimeoutSafeFetchTimeoutError, + timeoutSafeFetch, +} from "@/lib/server/timeout-safe-fetch"; +import { + buildSocialBladeBackendErrorPayload, + buildSocialBladeTimeoutResponse, +} from "@/lib/server/trr-api/socialblade-proxy"; + +export const dynamic = "force-dynamic"; + +const SOCIALBLADE_COOKIE_HEALTH_TIMEOUT_MS = 50_000; + +export async function GET(request: NextRequest) { + try { + await requireAdmin(request); + + const query = request.nextUrl.searchParams.toString(); + const backendUrl = getBackendApiUrl(`/admin/people/socialblade/cookies/health${query ? `?${query}` : ""}`); + if (!backendUrl) { + return NextResponse.json( + { error: "Backend API not configured (TRR_API_URL)" }, + { status: 502 }, + ); + } + + let headers: Headers; + try { + headers = buildInternalAdminHeaders(); + } catch { + return NextResponse.json({ error: "Backend auth not configured" }, { status: 502 }); + } + + const upstream = await timeoutSafeFetch(backendUrl, { + headers, + timeoutMs: SOCIALBLADE_COOKIE_HEALTH_TIMEOUT_MS, + timeoutName: "socialblade-cookie-health", + }); + const data = await upstream.json().catch(() => ({ error: "Invalid response from backend" })); + + if (!upstream.ok) { + return NextResponse.json( + buildSocialBladeBackendErrorPayload(data, `Backend returned ${upstream.status}`), + { status: upstream.status }, + ); + } + + return NextResponse.json(data); + } catch (error) { + if (isTimeoutSafeFetchTimeoutError(error)) { + return buildSocialBladeTimeoutResponse(error, SOCIALBLADE_COOKIE_HEALTH_TIMEOUT_MS); + } + console.error("[api] Failed to get SocialBlade cookie health", error); + const message = error instanceof Error ? error.message : "failed"; + const status = message === "unauthorized" ? 401 : message === "forbidden" ? 403 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social-growth/history/route.ts b/apps/web/src/app/api/admin/trr-api/social-growth/history/route.ts new file mode 100644 index 00000000..a912c2bb --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social-growth/history/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { getBackendApiUrl } from "@/lib/server/trr-api/backend"; +import { buildInternalAdminHeaders } from "@/lib/server/trr-api/internal-admin-auth"; +import { + isTimeoutSafeFetchTimeoutError, + timeoutSafeFetch, +} from "@/lib/server/timeout-safe-fetch"; +import { + buildSocialBladeBackendErrorPayload, + buildSocialBladeTimeoutResponse, +} from "@/lib/server/trr-api/socialblade-proxy"; + +export const dynamic = "force-dynamic"; + +const SOCIALBLADE_HISTORY_TIMEOUT_MS = 20_000; + +export async function GET(request: NextRequest) { + try { + await requireAdmin(request); + + const query = request.nextUrl.searchParams.toString(); + const backendUrl = getBackendApiUrl(`/admin/people/socialblade/history${query ? `?${query}` : ""}`); + if (!backendUrl) { + return NextResponse.json( + { error: "Backend API not configured (TRR_API_URL)" }, + { status: 502 } + ); + } + + let headers: Headers; + try { + headers = buildInternalAdminHeaders(); + } catch { + return NextResponse.json({ error: "Backend auth not configured" }, { status: 502 }); + } + + const upstream = await timeoutSafeFetch(backendUrl, { + headers, + timeoutMs: SOCIALBLADE_HISTORY_TIMEOUT_MS, + timeoutName: "socialblade-history", + }); + const data = await upstream.json().catch(() => ({ error: "Invalid response from backend" })); + + if (!upstream.ok) { + return NextResponse.json( + buildSocialBladeBackendErrorPayload(data, `Backend returned ${upstream.status}`), + { status: upstream.status } + ); + } + + return NextResponse.json(data); + } catch (error) { + if (isTimeoutSafeFetchTimeoutError(error)) { + return buildSocialBladeTimeoutResponse(error, SOCIALBLADE_HISTORY_TIMEOUT_MS); + } + console.error("[api] Failed to get SocialBlade history", error); + const message = error instanceof Error ? error.message : "failed"; + const status = message === "unauthorized" ? 401 : message === "forbidden" ? 403 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/ingest/media-mirror/drain-account/route.ts b/apps/web/src/app/api/admin/trr-api/social/ingest/media-mirror/drain-account/route.ts new file mode 100644 index 00000000..3666550a --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/ingest/media-mirror/drain-account/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + fetchSocialBackendJson, + SOCIAL_PROXY_DEFAULT_TIMEOUT_MS, + socialProxyErrorResponse, +} from "@/lib/server/trr-api/social-admin-proxy"; +import { isValidUuid } from "@/lib/server/validation/identifiers"; + +export const dynamic = "force-dynamic"; + +const CONFIRM_DRAIN = "DRAIN BRAVO MEDIA"; +const MEDIA_DRAIN_STAGES = new Set(["media_mirror", "comment_media_mirror", "all"]); + +const normalizePositiveInt = (value: unknown, fallback: number): number => { + const parsed = typeof value === "number" ? value : Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; + +export async function POST(request: NextRequest) { + try { + await requireAdmin(request); + + const rawBody = (await request.json().catch(() => ({}))) as { + run_id?: unknown; + account_handle?: unknown; + stage?: unknown; + recover_limit?: unknown; + dispatch_limit?: unknown; + confirm_drain?: unknown; + }; + const runId = typeof rawBody.run_id === "string" ? rawBody.run_id.trim() : ""; + const accountHandle = + typeof rawBody.account_handle === "string" + ? rawBody.account_handle.trim().replace(/^@/, "") + : ""; + const stage = typeof rawBody.stage === "string" ? rawBody.stage.trim() : "media_mirror"; + const confirmDrain = typeof rawBody.confirm_drain === "string" ? rawBody.confirm_drain : ""; + + if (!isValidUuid(runId)) { + return NextResponse.json( + { error: "run_id must be a valid UUID", code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + if (!accountHandle) { + return NextResponse.json( + { error: "account_handle is required", code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + if (!MEDIA_DRAIN_STAGES.has(stage)) { + return NextResponse.json( + { error: "stage must be media_mirror, comment_media_mirror, or all", code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + if (confirmDrain !== CONFIRM_DRAIN) { + return NextResponse.json( + { error: `confirm_drain must equal ${CONFIRM_DRAIN}`, code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + + const body = { + run_id: runId, + account_handle: accountHandle, + stage, + recover_limit: normalizePositiveInt(rawBody.recover_limit, 25), + dispatch_limit: normalizePositiveInt(rawBody.dispatch_limit, 8), + confirm_drain: confirmDrain, + }; + + const data = await fetchSocialBackendJson("/ingest/media-mirror/drain-account", { + method: "POST", + body: JSON.stringify(body), + headers: { + "content-type": "application/json", + }, + fallbackError: "Failed to drain Bravo media mirror jobs", + retries: 0, + timeoutMs: SOCIAL_PROXY_DEFAULT_TIMEOUT_MS, + }); + + return NextResponse.json(data); + } catch (error) { + return socialProxyErrorResponse(error, "[api] Failed to drain Bravo media mirror jobs"); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/ingest/media-mirror/recover-stale/route.ts b/apps/web/src/app/api/admin/trr-api/social/ingest/media-mirror/recover-stale/route.ts new file mode 100644 index 00000000..6bd6831a --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/ingest/media-mirror/recover-stale/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + fetchSocialBackendJson, + SOCIAL_PROXY_DEFAULT_TIMEOUT_MS, + socialProxyErrorResponse, +} from "@/lib/server/trr-api/social-admin-proxy"; +import { isValidUuid } from "@/lib/server/validation/identifiers"; + +export const dynamic = "force-dynamic"; + +const CONFIRM_RECOVERY = "RECOVER MEDIA MIRROR JOBS"; +const MEDIA_RECOVERY_STAGES = new Set(["media_mirror", "comment_media_mirror", "all"]); + +export async function POST(request: NextRequest) { + try { + await requireAdmin(request); + + const rawBody = (await request.json().catch(() => ({}))) as { + run_id?: unknown; + stage?: unknown; + stale_after_seconds?: unknown; + recover_limit?: unknown; + dispatch_limit?: unknown; + skip_dispatch?: unknown; + confirm_recovery?: unknown; + }; + const runId = typeof rawBody.run_id === "string" ? rawBody.run_id.trim() : ""; + const stage = typeof rawBody.stage === "string" ? rawBody.stage.trim() : "media_mirror"; + const confirmRecovery = + typeof rawBody.confirm_recovery === "string" ? rawBody.confirm_recovery : ""; + + if (!isValidUuid(runId)) { + return NextResponse.json( + { error: "run_id must be a valid UUID", code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + if (!MEDIA_RECOVERY_STAGES.has(stage)) { + return NextResponse.json( + { error: "stage must be media_mirror, comment_media_mirror, or all", code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + if (confirmRecovery !== CONFIRM_RECOVERY) { + return NextResponse.json( + { error: `confirm_recovery must equal ${CONFIRM_RECOVERY}`, code: "BAD_REQUEST", retryable: false }, + { status: 400 }, + ); + } + + const body = { + run_id: runId, + stage, + stale_after_seconds: Number(rawBody.stale_after_seconds) || 900, + recover_limit: Number(rawBody.recover_limit) || 5, + dispatch_limit: Number(rawBody.dispatch_limit) || 8, + skip_dispatch: Boolean(rawBody.skip_dispatch), + confirm_recovery: confirmRecovery, + }; + + const data = await fetchSocialBackendJson("/ingest/media-mirror/recover-stale", { + method: "POST", + body: JSON.stringify(body), + headers: { + "content-type": "application/json", + }, + fallbackError: "Failed to recover stale media mirror jobs", + retries: 0, + timeoutMs: SOCIAL_PROXY_DEFAULT_TIMEOUT_MS, + }); + + return NextResponse.json(data); + } catch (error) { + return socialProxyErrorResponse(error, "[api] Failed to recover stale media mirror jobs"); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress/route.ts index 707dee5b..02c64aa9 100644 --- a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress/route.ts +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress/route.ts @@ -23,7 +23,6 @@ const elapsedMs = (start: number): number => Math.round(performance.now() - star const PROGRESS_CACHE_NAMESPACE = "social-account-catalog-run-progress"; const PROGRESS_CACHE_TTL_MS = 5_000; const PROGRESS_CACHE_STALE_MS = 60_000; -const FAST_PROGRESS_TIMEOUT_MS = 12_000; const markProgressDegraded = (value: unknown, reason: string): unknown => { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -101,7 +100,7 @@ export async function GET(request: NextRequest, context: RouteContext) { const upstreamStart = performance.now(); let data: unknown; const isFastPoll = isFastProgressRequest(request); - const timeoutMs = isFastPoll ? FAST_PROGRESS_TIMEOUT_MS : SOCIAL_PROXY_PROGRESS_TIMEOUT_MS; + const timeoutMs = SOCIAL_PROXY_PROGRESS_TIMEOUT_MS; try { const fetchProgress = () => fetchSocialBackendJson(path, { @@ -158,7 +157,7 @@ export async function GET(request: NextRequest, context: RouteContext) { upstream_ms: upstreamMs, response_ms: responseMs, total_ms: elapsedMs(totalStart), - timeout_ms: isFastProgressRequest(request) ? FAST_PROGRESS_TIMEOUT_MS : SOCIAL_PROXY_PROGRESS_TIMEOUT_MS, + timeout_ms: SOCIAL_PROXY_PROGRESS_TIMEOUT_MS, retries: 0, failed: true, }); diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent/route.ts new file mode 100644 index 00000000..2911b798 --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent/route.ts @@ -0,0 +1,349 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + buildUserScopedRouteCacheKey, + getOrCreateRouteResponsePromise, + getRouteResponseCache, + getStaleRouteResponseCache, + parseCacheTtlMs, + setRouteResponseCache, +} from "@/lib/server/admin/route-response-cache"; +import { query } from "@/lib/server/postgres"; +import { socialProxyErrorResponse } from "@/lib/server/trr-api/social-admin-proxy"; +import type { SocialAccountCatalogRun } from "@/lib/admin/social-account-profile"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ platform: string; handle: string }>; +}; + +type RecentCatalogRunRow = { + job_id: string | null; + run_id: string | null; + status: string | null; + created_at: string | Date | null; + started_at: string | Date | null; + completed_at: string | Date | null; + error_message: string | null; + run_config: Record | null; +}; + +const CATALOG_BACKFILL_INGEST_MODE = "shared_account_catalog_backfill"; +const CATALOG_RECENT_RUNS_CACHE_NAMESPACE = "social-account-catalog-recent-runs"; +const CATALOG_RECENT_RUNS_CACHE_TTL_MS = parseCacheTtlMs( + process.env.TRR_ADMIN_SOCIAL_PROFILE_CATALOG_RECENT_RUNS_CACHE_TTL_MS, + 10_000, +); +const CATALOG_RECENT_RUNS_STALE_CACHE_TTL_MS = parseCacheTtlMs( + process.env.TRR_ADMIN_SOCIAL_PROFILE_CATALOG_RECENT_RUNS_STALE_CACHE_TTL_MS, + 60_000, +); +const CATALOG_RECENT_RUN_STAGES = [ + "shared_account_discovery", + "shared_account_posts", + "tiktok_posts_scrapling", + "threads_posts_scrapling", + "post_classify", + "season_materialize", + "analytics_refresh", +]; +const SELECTED_TASKS = new Set(["post_details", "comments", "media"]); +const ACTIVE_FOLLOWUP_STATES = new Set(["queued", "pending", "retrying", "running", "attached", "cancelling"]); +const TERMINAL_PARENT_STATUSES = new Set(["cancelled", "failed"]); + +const normalizeHandle = (value: string): string => + value.trim().toLowerCase().replace(/^@+/, ""); + +const readLimit = (request: NextRequest): number => { + const parsed = Number(request.nextUrl.searchParams.get("limit") ?? "10"); + return Number.isInteger(parsed) ? Math.max(1, Math.min(parsed, 25)) : 10; +}; + +const readRecord = (value: unknown): Record => + value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; + +const readString = (value: unknown): string | null => { + const text = String(value ?? "").trim(); + return text || null; +}; + +const readLowerString = (value: unknown): string | null => { + const text = readString(value)?.toLowerCase() ?? ""; + return text || null; +}; + +const normalizeIso = (value: string | Date | null | undefined): string | null => { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + const text = String(value).trim(); + return text || null; +}; + +const normalizeTasks = (value: unknown): Array<"post_details" | "comments" | "media"> => { + if (!Array.isArray(value)) return []; + const tasks: Array<"post_details" | "comments" | "media"> = []; + for (const item of value) { + const normalized = String(item ?? "").trim().toLowerCase(); + if (SELECTED_TASKS.has(normalized) && !tasks.includes(normalized as (typeof tasks)[number])) { + tasks.push(normalized as (typeof tasks)[number]); + } + } + return tasks; +}; + +const normalizeCommentsSource = ( + value: unknown, +): "new_run" | "reused_run" | "deferred_after_catalog" | null => { + const normalized = readLowerString(value); + if (normalized === "new_run" || normalized === "reused_run" || normalized === "deferred_after_catalog") { + return normalized; + } + return null; +}; + +const coerceAttachedStateForParent = (parentStatus: string | null, state: string | null): string | null => { + if (parentStatus && TERMINAL_PARENT_STATUSES.has(parentStatus) && state && ACTIVE_FOLLOWUP_STATES.has(state)) { + return parentStatus; + } + return state; +}; + +const normalizeAttachedFollowups = ( + runConfig: Record, + runStatus: string | null, +): SocialAccountCatalogRun["attached_followups"] => { + const raw = readRecord(runConfig.attached_followups); + const comments = readRecord(raw.comments); + const media = readRecord(raw.media); + const attached: NonNullable = {}; + + const commentsRunId = readString(comments.run_id ?? runConfig.comments_run_id); + const commentsStatus = readLowerString(comments.status); + const commentsState = coerceAttachedStateForParent(runStatus, readLowerString(comments.state)); + const commentsSource = normalizeCommentsSource(comments.source); + if (commentsRunId || commentsStatus || commentsState || commentsSource) { + attached.comments = { + run_id: commentsRunId, + status: runStatus && TERMINAL_PARENT_STATUSES.has(runStatus) && !commentsRunId ? runStatus : commentsStatus, + state: commentsState, + source: commentsSource ?? "deferred_after_catalog", + error_message: readString(comments.error_message), + failed_at: readString(comments.failed_at), + retryable: typeof comments.retryable === "boolean" ? comments.retryable : null, + }; + } + + const mediaJobIds = Array.isArray(media.enqueued_job_ids) + ? media.enqueued_job_ids.map((item) => String(item ?? "").trim()).filter(Boolean) + : []; + const mediaStatus = readLowerString(media.status); + const mediaState = coerceAttachedStateForParent(runStatus, readLowerString(media.state)); + const mediaSource = readLowerString(media.source); + const mediaCount = Number(media.enqueued_job_count ?? mediaJobIds.length); + if (readString(media.attachment_id) || mediaStatus || mediaState || mediaSource || mediaJobIds.length > 0 || mediaCount > 0) { + attached.media = { + attachment_id: readString(media.attachment_id), + status: runStatus && TERMINAL_PARENT_STATUSES.has(runStatus) && !mediaJobIds.length ? runStatus : mediaStatus, + state: mediaState, + source: mediaSource === "comments_media_followups" ? "comments_media_followups" : "catalog_media_mirror", + enqueued_job_ids: mediaJobIds, + enqueued_job_count: Number.isFinite(mediaCount) ? Math.max(0, mediaCount) : mediaJobIds.length, + }; + } + + return Object.keys(attached).length > 0 ? attached : {}; +}; + +const normalizeRun = (row: RecentCatalogRunRow): SocialAccountCatalogRun => { + const runConfig = readRecord(row.run_config); + const status = readLowerString(row.status); + const selectedTasks = normalizeTasks(runConfig.selected_tasks); + const effectiveTasks = normalizeTasks(runConfig.effective_selected_tasks); + return { + job_id: readString(row.job_id) ?? "", + run_id: readString(row.run_id) ?? "", + status, + created_at: normalizeIso(row.created_at), + started_at: normalizeIso(row.started_at), + completed_at: normalizeIso(row.completed_at), + error_message: readString(row.error_message), + catalog_action: readLowerString(runConfig.catalog_action) as SocialAccountCatalogRun["catalog_action"], + catalog_action_scope: readLowerString(runConfig.catalog_action_scope) as SocialAccountCatalogRun["catalog_action_scope"], + date_start: normalizeIso(readString(runConfig.date_start)), + date_end: normalizeIso(readString(runConfig.date_end)), + launch_group_id: readString(runConfig.launch_group_id), + launch_state: readLowerString(runConfig.launch_state) as SocialAccountCatalogRun["launch_state"], + selected_tasks: selectedTasks, + effective_selected_tasks: effectiveTasks.length > 0 ? effectiveTasks : selectedTasks, + comments_run_id: readString(runConfig.comments_run_id), + attached_followups: normalizeAttachedFollowups(runConfig, status), + } as SocialAccountCatalogRun; +}; + +const fetchRecentRunsPayload = async ({ + normalizedPlatform, + normalizedHandle, + limit, +}: { + normalizedPlatform: string; + normalizedHandle: string; + limit: number; +}) => { + const result = await query( + ` + with scoped_runs as ( + select + r.id as run_uuid, + r.id::text as run_id, + coalesce(r.config, '{}'::jsonb) as run_config, + r.status as run_status, + r.created_at as run_created_at, + r.started_at as run_started_at, + r.completed_at as run_completed_at + from social.scrape_runs r + where coalesce(r.config->>'pipeline_ingest_mode', '') = $1 + and nullif(coalesce(r.config->>'failure_dismissed_at', ''), '') is null + and ( + exists ( + select 1 + from social.scrape_jobs j + where j.run_id = r.id + and j.platform = $2 + and lower(coalesce(nullif(j.config->>'account', ''), nullif(j.metadata->>'account', ''), '')) = $3 + and lower( + coalesce( + nullif(j.config->>'stage', ''), + nullif(j.metadata->>'stage', ''), + nullif(j.job_type, ''), + 'unknown' + ) + ) = any($4::text[]) + ) + or ( + ( + lower(coalesce(r.config->>'launch_state', '')) = 'pending' + or lower(coalesce(r.config->>'launch_task_resolution_pending', 'false')) = 'true' + ) + and lower(coalesce(nullif(r.config->>'platform', ''), nullif(r.config->'platforms'->>0, ''), '')) = $2 + and ltrim( + lower( + coalesce( + nullif(r.config->>'account_handle', ''), + nullif(r.config->>'account', ''), + nullif(r.config->'accounts_override'->>0, ''), + '' + ) + ), + '@' + ) = $3 + ) + ) + ) + select + latest_job.job_id, + scoped_runs.run_id, + coalesce(nullif(lower(coalesce(scoped_runs.run_status, '')), ''), latest_job.job_status) as status, + scoped_runs.run_created_at as created_at, + scoped_runs.run_started_at as started_at, + scoped_runs.run_completed_at as completed_at, + latest_error.error_message as error_message, + scoped_runs.run_config + from scoped_runs + left join lateral ( + select + j.id::text as job_id, + lower(coalesce(nullif(j.status, ''), '')) as job_status + from social.scrape_jobs j + where j.run_id = scoped_runs.run_uuid + and j.platform = $2 + and lower(coalesce(nullif(j.config->>'account', ''), nullif(j.metadata->>'account', ''), '')) = $3 + and lower( + coalesce( + nullif(j.config->>'stage', ''), + nullif(j.metadata->>'stage', ''), + nullif(j.job_type, ''), + 'unknown' + ) + ) = any($4::text[]) + order by coalesce(j.completed_at, j.started_at, j.created_at) desc, j.id desc + limit 1 + ) latest_job on true + left join lateral ( + select j.error_message + from social.scrape_jobs j + where j.run_id = scoped_runs.run_uuid + and j.platform = $2 + and lower(coalesce(nullif(j.config->>'account', ''), nullif(j.metadata->>'account', ''), '')) = $3 + and lower( + coalesce( + nullif(j.config->>'stage', ''), + nullif(j.metadata->>'stage', ''), + nullif(j.job_type, ''), + 'unknown' + ) + ) = any($4::text[]) + and nullif(j.error_message, '') is not null + order by coalesce(j.completed_at, j.started_at, j.created_at) desc, j.id desc + limit 1 + ) latest_error on true + order by scoped_runs.run_created_at desc, scoped_runs.run_id desc + limit $5 + `, + [CATALOG_BACKFILL_INGEST_MODE, normalizedPlatform, normalizedHandle, CATALOG_RECENT_RUN_STAGES, limit], + ); + const runs = (result.rows ?? []) + .map(normalizeRun) + .filter((run) => String(run.run_id || "").trim().length > 0); + return { + platform: normalizedPlatform, + handle: normalizedHandle, + catalog_recent_runs: runs, + }; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + try { + const user = await requireAdmin(request); + const { platform, handle } = await context.params; + const normalizedPlatform = platform.trim().toLowerCase(); + const normalizedHandle = normalizeHandle(handle); + if (normalizedPlatform !== "instagram" || !normalizedHandle) { + return NextResponse.json({ error: "unsupported_profile" }, { status: 400 }); + } + const limit = readLimit(request); + const cacheKey = buildUserScopedRouteCacheKey( + String(user?.uid ?? "admin"), + `${normalizedPlatform}:${normalizedHandle}:catalog-runs-recent`, + request.nextUrl.searchParams, + ); + const cached = getRouteResponseCache(CATALOG_RECENT_RUNS_CACHE_NAMESPACE, cacheKey); + if (cached) { + return NextResponse.json(cached, { headers: { "x-trr-cache": "hit" } }); + } + const stale = getStaleRouteResponseCache(CATALOG_RECENT_RUNS_CACHE_NAMESPACE, cacheKey); + try { + const payload = await getOrCreateRouteResponsePromise(CATALOG_RECENT_RUNS_CACHE_NAMESPACE, cacheKey, async () => { + const nextPayload = await fetchRecentRunsPayload({ normalizedPlatform, normalizedHandle, limit }); + setRouteResponseCache( + CATALOG_RECENT_RUNS_CACHE_NAMESPACE, + cacheKey, + nextPayload, + CATALOG_RECENT_RUNS_CACHE_TTL_MS, + CATALOG_RECENT_RUNS_STALE_CACHE_TTL_MS, + ); + return nextPayload; + }); + return NextResponse.json(payload, { headers: { "x-trr-cache": "miss" } }); + } catch (error) { + if (stale) { + return NextResponse.json(stale, { + headers: { "x-trr-cache": "stale", "x-trr-cacheable": "0" }, + }); + } + throw error; + } + } catch (error) { + return socialProxyErrorResponse(error, "[api] Failed to load social account catalog recent runs"); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup/route.ts new file mode 100644 index 00000000..1df8d92c --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + fetchSocialBackendJson, + socialProxyErrorResponse, +} from "@/lib/server/trr-api/social-admin-proxy"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ platform: string; handle: string; runId: string }>; +}; + +export async function POST(request: NextRequest, context: RouteContext) { + const { platform, handle, runId } = await context.params; + + try { + await requireAdmin(request); + const body = await request.text(); + const data = await fetchSocialBackendJson( + `/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/runs/${encodeURIComponent(runId)}/authenticated-followup`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + fallbackError: "Failed to start authenticated follow-up for this comments run", + retries: 0, + timeoutMs: 210_000, + }, + ); + return NextResponse.json(data); + } catch (error) { + return socialProxyErrorResponse( + error, + "[api] Failed to start authenticated follow-up for this comments run", + ); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery/route.ts new file mode 100644 index 00000000..f2e18204 --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + fetchSocialBackendJson, + socialProxyErrorResponse, +} from "@/lib/server/trr-api/social-admin-proxy"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ platform: string; handle: string; runId: string }>; +}; + +export async function POST(request: NextRequest, context: RouteContext) { + const { platform, handle, runId } = await context.params; + + try { + await requireAdmin(request); + const body = await request.text(); + const data = await fetchSocialBackendJson( + `/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/runs/${encodeURIComponent(runId)}/public-recovery`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + fallbackError: "Failed to start public recovery for this comments run", + retries: 0, + timeoutMs: 210_000, + }, + ); + return NextResponse.json(data); + } catch (error) { + return socialProxyErrorResponse( + error, + "[api] Failed to start public recovery for this comments run", + ); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth/route.ts new file mode 100644 index 00000000..48262315 --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + fetchSocialBackendJson, + socialProxyErrorResponse, +} from "@/lib/server/trr-api/social-admin-proxy"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ platform: string; handle: string; runId: string }>; +}; + +export async function POST(request: NextRequest, context: RouteContext) { + const { platform, handle, runId } = await context.params; + + try { + await requireAdmin(request); + const body = await request.text(); + const data = await fetchSocialBackendJson( + `/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/runs/${encodeURIComponent(runId)}/repair-auth`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + fallbackError: "Failed to repair Instagram auth for this comments run", + retries: 0, + timeoutMs: 210_000, + }, + ); + return NextResponse.json(data); + } catch (error) { + return socialProxyErrorResponse(error, "[api] Failed to repair Instagram auth for this comments run"); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary/route.ts new file mode 100644 index 00000000..d04591ad --- /dev/null +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary/route.ts @@ -0,0 +1,365 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/server/auth"; +import { + buildUserScopedRouteCacheKey, + getOrCreateRouteResponsePromise, + getRouteResponseCache, + getStaleRouteResponseCache, + parseCacheTtlMs, + setRouteResponseCache, +} from "@/lib/server/admin/route-response-cache"; +import { query } from "@/lib/server/postgres"; +import { socialProxyErrorResponse } from "@/lib/server/trr-api/social-admin-proxy"; + +export const dynamic = "force-dynamic"; + +type RouteContext = { + params: Promise<{ platform: string; handle: string }>; +}; + +type CompletionRow = { + total_posts: number | string | null; + total_reported_comments: number | string | null; + saved_comments: number | string | null; + missing_comments: number | string | null; + accounted_comments: number | string | null; + comments_finished: number | string | null; + comments_in_progress: number | string | null; + comments_not_started: number | string | null; + details_finished: number | string | null; + details_not_started: number | string | null; + media_finished: number | string | null; + media_in_progress: number | string | null; + media_not_started: number | string | null; +}; + +const COMPLETION_SUMMARY_CACHE_NAMESPACE = "social-account-profile-completion-summary"; +const COMPLETION_SUMMARY_CACHE_TTL_MS = parseCacheTtlMs( + process.env.TRR_ADMIN_SOCIAL_PROFILE_COMPLETION_SUMMARY_CACHE_TTL_MS, + 5 * 60_000, +); +const COMPLETION_SUMMARY_STALE_CACHE_TTL_MS = parseCacheTtlMs( + process.env.TRR_ADMIN_SOCIAL_PROFILE_COMPLETION_SUMMARY_STALE_CACHE_TTL_MS, + 10 * 60_000, +); + +const readCount = (value: number | string | null | undefined): number => { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const getDefaultYear = (): number => new Date().getUTCFullYear(); + +const readYear = (request: NextRequest): number => { + const fallbackYear = getDefaultYear(); + const parsed = Number(request.nextUrl.searchParams.get("year") ?? String(fallbackYear)); + return Number.isInteger(parsed) && parsed >= 2000 && parsed <= 2100 ? parsed : fallbackYear; +}; + +const sqlJsonTextNonNegativeInt = (expr: string): string => + `coalesce(nullif(regexp_replace(coalesce(${expr}, ''), '[^0-9]', '', 'g'), '')::bigint, 0)`; + +const instagramRawReportedCommentsSql = (alias: string): string => { + const safeAlias = alias.trim() || "p"; + const raw = `coalesce(${safeAlias}.raw_data, '{}'::jsonb)`; + return `greatest(${[ + `${raw} ->> 'comments_count'`, + `${raw} ->> 'comments'`, + `${raw} ->> 'comment_count'`, + `${raw} ->> 'commentsCount'`, + `${raw} -> 'edge_media_to_comment' ->> 'count'`, + `${raw} -> 'edge_media_to_parent_comment' ->> 'count'`, + `${raw} -> 'edge_media_preview_comment' ->> 'count'`, + `${raw} -> 'media' ->> 'comments_count'`, + `${raw} -> 'media' ->> 'comments'`, + `${raw} -> 'media' ->> 'comment_count'`, + `${raw} -> 'media' ->> 'commentsCount'`, + `${raw} -> 'metrics' ->> 'comments_count'`, + `${raw} -> 'metrics' ->> 'comments'`, + ] + .map(sqlJsonTextNonNegativeInt) + .join(", ")}, 0)`; +}; + +export async function GET(request: NextRequest, context: RouteContext) { + try { + const user = await requireAdmin(request); + const { platform, handle } = await context.params; + const normalizedPlatform = platform.trim().toLowerCase(); + const normalizedHandle = handle.trim().toLowerCase().replace(/^@/, ""); + if (normalizedPlatform !== "instagram" || !normalizedHandle) { + return NextResponse.json({ error: "unsupported_profile" }, { status: 400 }); + } + const year = readYear(request); + const cacheKey = buildUserScopedRouteCacheKey( + String(user?.uid ?? "admin"), + `${normalizedPlatform}:${normalizedHandle}:completion-summary`, + new URLSearchParams([["year", String(year)]]), + ); + const cached = getRouteResponseCache(COMPLETION_SUMMARY_CACHE_NAMESPACE, cacheKey); + if (cached) { + return NextResponse.json(cached, { headers: { "x-trr-cache": "hit" } }); + } + const stale = getStaleRouteResponseCache(COMPLETION_SUMMARY_CACHE_NAMESPACE, cacheKey); + const loadPayload = async () => { + const instagramPostReportedCommentsSql = instagramRawReportedCommentsSql("p"); + const result = await query( + ` + with target as ( + select + $1::text as handle, + make_timestamptz($2, 1, 1, 0, 0, 0) as start_at, + make_timestamptz($2 + 1, 1, 1, 0, 0, 0) as end_at + ), + catalog_candidates as materialized ( + select + cp.source_id as shortcode, + cp.posted_at, + coalesce(cp.comments_count, 0)::bigint as catalog_comments_count + from social.instagram_account_catalog_posts cp + cross join target t + where cp.posted_at >= t.start_at + and cp.posted_at < t.end_at + and nullif(cp.source_id, '') is not null + and ( + nullif(regexp_replace(lower(regexp_replace(coalesce(cp.source_account, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), '') = t.handle + or nullif(regexp_replace(lower(regexp_replace(coalesce(cp.owner_username, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), '') = t.handle + or nullif( + regexp_replace( + lower( + regexp_replace( + coalesce( + cp.raw_data ->> 'username', + cp.raw_data ->> 'ownerUsername', + cp.raw_data -> 'owner' ->> 'username', + cp.raw_data -> 'user' ->> 'username', + '' + ), + '^@+', + '' + ) + ), + '[^a-z0-9._-]+', + '', + 'g' + ), + '' + ) = t.handle + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(cp.collaborators, '[]'::jsonb)) collaborator(value) + where nullif( + regexp_replace(lower(regexp_replace(coalesce(collaborator.value, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), + '' + ) = t.handle + ) + or exists ( + select 1 + from jsonb_array_elements( + coalesce(to_jsonb(cp) -> 'collaborators_detail', cp.raw_data -> 'collaborators_detail', '[]'::jsonb) + ) collaborator(detail) + where nullif( + regexp_replace( + lower(regexp_replace(coalesce(collaborator.detail ->> 'username', ''), '^@+', '')), + '[^a-z0-9._-]+', + '', + 'g' + ), + '' + ) = t.handle + ) + ) + ), + post_candidates as materialized ( + select + p.shortcode, + p.id as post_id, + p.posted_at, + (${instagramPostReportedCommentsSql})::bigint as detail_comments_count, + lower(coalesce(p.media_mirror_status, '')) as media_mirror_status, + greatest(coalesce(ch.saved_comment_count, r.active_comment_count, 0), 0)::bigint as saved_comments, + greatest(coalesce(ch.instagram_reported_comments, (${instagramPostReportedCommentsSql}), 0), 0)::bigint as health_reported_comments, + greatest( + coalesce( + r.missing_comment_count, + greatest( + coalesce(ch.instagram_reported_comments, (${instagramPostReportedCommentsSql}), 0)::bigint + - coalesce(ch.saved_comment_count, r.active_comment_count, 0)::bigint, + 0 + ), + 0 + ), + 0 + )::bigint as missing_comments + from social.instagram_posts p + left join social.instagram_post_comment_rollups r on r.post_id = p.id + left join social.comment_capture_health ch on ch.post_id = p.id + cross join target t + where p.posted_at >= t.start_at + and p.posted_at < t.end_at + and nullif(p.shortcode, '') is not null + and ( + nullif(regexp_replace(lower(regexp_replace(coalesce(p.source_account, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), '') = t.handle + or nullif(regexp_replace(lower(regexp_replace(coalesce(p.owner_username, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), '') = t.handle + or nullif(regexp_replace(lower(regexp_replace(coalesce(p.username, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), '') = t.handle + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(p.collaborators, '[]'::jsonb)) collaborator(value) + where nullif( + regexp_replace(lower(regexp_replace(coalesce(collaborator.value, ''), '^@+', '')), '[^a-z0-9._-]+', '', 'g'), + '' + ) = t.handle + ) + or exists ( + select 1 + from jsonb_array_elements( + coalesce(to_jsonb(p) -> 'collaborators_detail', p.raw_data -> 'collaborators_detail', '[]'::jsonb) + ) collaborator(detail) + where nullif( + regexp_replace( + lower(regexp_replace(coalesce(collaborator.detail ->> 'username', ''), '^@+', '')), + '[^a-z0-9._-]+', + '', + 'g' + ), + '' + ) = t.handle + ) + ) + ), + matched_shortcodes as materialized ( + select shortcode from catalog_candidates + union + select shortcode from post_candidates + ), + catalog as materialized ( + select + matched.shortcode, + max(c.catalog_comments_count)::bigint as catalog_comments_count + from matched_shortcodes matched + left join catalog_candidates c on c.shortcode = matched.shortcode + group by matched.shortcode + ), + latest_post as materialized ( + select distinct on (p.shortcode) + p.shortcode, + p.post_id, + coalesce(p.detail_comments_count, 0)::bigint as detail_comments_count, + lower(coalesce(p.media_mirror_status, '')) as media_mirror_status, + p.saved_comments, + p.health_reported_comments, + p.missing_comments, + p.posted_at + from post_candidates p + order by p.shortcode, p.posted_at desc nulls last, p.post_id desc + ), + scored as ( + select + matched.shortcode, + lp.post_id, + greatest( + coalesce(c.catalog_comments_count, 0), + coalesce(lp.health_reported_comments, 0), + coalesce(lp.detail_comments_count, 0) + )::bigint as reported_comments, + coalesce(lp.saved_comments, 0)::bigint as saved_comments, + greatest( + coalesce( + lp.missing_comments, + greatest( + greatest( + coalesce(c.catalog_comments_count, 0), + coalesce(lp.health_reported_comments, 0), + coalesce(lp.detail_comments_count, 0) + ) - coalesce(lp.saved_comments, 0), + 0 + ), + 0 + ), + 0 + )::bigint as missing_comments, + lp.media_mirror_status + from matched_shortcodes matched + left join catalog c on c.shortcode = matched.shortcode + left join latest_post lp on lp.shortcode = matched.shortcode + ) + select + count(*)::bigint as total_posts, + coalesce(sum(reported_comments), 0)::bigint as total_reported_comments, + coalesce(sum(saved_comments), 0)::bigint as saved_comments, + coalesce(sum(missing_comments), 0)::bigint as missing_comments, + coalesce(sum(saved_comments + missing_comments), 0)::bigint as accounted_comments, + count(*) filter ( + where reported_comments = 0 + or (reported_comments > 0 and missing_comments = 0 and reported_comments <= saved_comments) + )::bigint as comments_finished, + count(*) filter ( + where reported_comments > 0 and saved_comments > 0 and missing_comments > 0 + )::bigint as comments_in_progress, + count(*) filter (where reported_comments > 0 and saved_comments = 0)::bigint as comments_not_started, + count(*) filter (where post_id is not null)::bigint as details_finished, + count(*) filter (where post_id is null)::bigint as details_not_started, + count(*) filter (where media_mirror_status in ('complete', 'completed', 'mirrored', 'up_to_date'))::bigint as media_finished, + count(*) filter (where media_mirror_status in ('pending', 'partial', 'queued', 'retrying', 'running', 'failed'))::bigint as media_in_progress, + count(*) filter ( + where post_id is null + or coalesce(media_mirror_status, '') not in ('complete', 'completed', 'mirrored', 'up_to_date', 'pending', 'partial', 'queued', 'retrying', 'running', 'failed') + )::bigint as media_not_started + from scored + `, + [normalizedHandle, year], + ); + const row = result.rows[0] ?? ({} as CompletionRow); + return { + platform: normalizedPlatform, + handle: normalizedHandle, + year, + total_posts: readCount(row.total_posts), + total_reported_comments: readCount(row.total_reported_comments), + saved_comments: readCount(row.saved_comments), + missing_comments: readCount(row.missing_comments), + accounted_comments: readCount(row.accounted_comments), + lanes: { + comments: { + finished: readCount(row.comments_finished), + in_progress: readCount(row.comments_in_progress), + not_started: readCount(row.comments_not_started), + }, + details: { + finished: readCount(row.details_finished), + in_progress: 0, + not_started: readCount(row.details_not_started), + }, + media: { + finished: readCount(row.media_finished), + in_progress: readCount(row.media_in_progress), + not_started: readCount(row.media_not_started), + }, + }, + }; + }; + try { + const payload = await getOrCreateRouteResponsePromise(COMPLETION_SUMMARY_CACHE_NAMESPACE, cacheKey, async () => { + const nextPayload = await loadPayload(); + setRouteResponseCache( + COMPLETION_SUMMARY_CACHE_NAMESPACE, + cacheKey, + nextPayload, + COMPLETION_SUMMARY_CACHE_TTL_MS, + COMPLETION_SUMMARY_STALE_CACHE_TTL_MS, + ); + return nextPayload; + }); + return NextResponse.json(payload, { headers: { "x-trr-cache": "miss" } }); + } catch (error) { + if (stale) { + return NextResponse.json(stale, { + headers: { "x-trr-cache": "stale", "x-trr-cacheable": "0" }, + }); + } + throw error; + } + } catch (error) { + return socialProxyErrorResponse(error, "[api] Failed to load social completion summary"); + } +} diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/live-profile-total/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/live-profile-total/route.ts index e20d205e..632a0103 100644 --- a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/live-profile-total/route.ts +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/live-profile-total/route.ts @@ -21,6 +21,19 @@ type RouteContext = { const LIVE_PROFILE_TOTAL_CACHE_NAMESPACE = "admin-social-profile-live-total"; const LIVE_PROFILE_TOTAL_CACHE_TTL_MS = 5 * 60_000; const LIVE_PROFILE_TOTAL_STALE_TTL_MS = 15 * 60_000; +const LIVE_PROFILE_TOTAL_BACKEND_TIMEOUT_MS = 4_000; + +const buildLiveProfileTotalFallback = (platform: string, handle: string, reason: string): Record => ({ + platform, + handle, + account_handle: handle, + live_total_posts_current: null, + profile_url: null, + status: "degraded", + degraded: true, + degraded_reason: reason, + generated_at: new Date().toISOString(), +}); export async function GET(request: NextRequest, context: RouteContext) { try { @@ -53,7 +66,7 @@ export async function GET(request: NextRequest, context: RouteContext) { adminContext, fallbackError: "Failed to fetch social account live profile total", retries: 0, - timeoutMs: 30_000, + timeoutMs: LIVE_PROFILE_TOTAL_BACKEND_TIMEOUT_MS, }, ); setRouteResponseCache( @@ -73,7 +86,12 @@ export async function GET(request: NextRequest, context: RouteContext) { headers: { "x-trr-cache": "stale", "x-trr-cacheable": "0" }, }); } - throw error; + return NextResponse.json(buildLiveProfileTotalFallback(platform, handle, "backend_timeout_or_error"), { + headers: { + "x-trr-cache": "fallback", + "x-trr-cacheable": "0", + }, + }); } } catch (error) { return socialProxyErrorResponse(error, "[api] Failed to fetch social account live profile total"); diff --git a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/snapshot/route.ts b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/snapshot/route.ts index 54b42180..80924805 100644 --- a/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/snapshot/route.ts +++ b/apps/web/src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/snapshot/route.ts @@ -10,6 +10,7 @@ import { buildSnapshotResponse } from "@/lib/server/admin/admin-snapshot-route"; import { fetchSocialBackendJson, SOCIAL_PROXY_LONG_TIMEOUT_MS, + SOCIAL_PROXY_PROGRESS_TIMEOUT_MS, socialProxyErrorResponse, } from "@/lib/server/trr-api/social-admin-proxy"; @@ -21,7 +22,6 @@ const LIVE_TTL_MS = 5 * 60_000; const LIVE_STALE_MS = 15 * 60_000; const RUN_PROGRESS_TTL_MS = 3_000; const RUN_PROGRESS_STALE_MS = 30_000; -const DIRECT_PROGRESS_TIMEOUT_MS = 12_000; const ACTIVE_PROFILE_WORK_STATUSES = new Set(["queued", "pending", "retrying", "running", "cancelling"]); export const dynamic = "force-dynamic"; @@ -50,26 +50,6 @@ const activeCatalogRunId = (summary: Record | null): string | n return null; }; -const hasActiveCommentsCoverage = (summary: Record | null): boolean => { - const coverage = readNestedRecord(summary?.comments_coverage); - if (!coverage) return false; - const activeRunId = String(coverage.active_run_id ?? "").trim(); - if (activeRunId) return true; - const status = String( - coverage.effective_status ?? coverage.last_attempt_status ?? coverage.last_comments_run_status ?? "", - ) - .trim() - .toLowerCase(); - return ACTIVE_PROFILE_WORK_STATUSES.has(status); -}; - -const hasActiveCatalogWork = (summary: Record | null): boolean => { - return activeCatalogRunId(summary) !== null; -}; - -const hasActiveProfileWork = (snapshot: SocialProfileSnapshotPayload): boolean => - hasActiveCommentsCoverage(snapshot.summary) || hasActiveCatalogWork(snapshot.summary); - const buildDegradedProgressPayload = (runId: string, reason: string): Record => ({ run_id: runId, run_status: "unknown", @@ -108,7 +88,7 @@ const fetchDirectCatalogProgress = async ({ fallbackError: "Failed to fetch social account catalog run progress", queryString: progressParams.toString(), retries: 0, - timeoutMs: DIRECT_PROGRESS_TIMEOUT_MS, + timeoutMs: SOCIAL_PROXY_PROGRESS_TIMEOUT_MS, }, )) as Record; return { progress, source: "direct-progress" }; @@ -239,22 +219,13 @@ export async function GET(request: NextRequest, context: RouteContext) { } satisfies SocialProfileSnapshotPayload; }; - let snapshot = await getOrCreateAdminSnapshot({ + const snapshot = await getOrCreateAdminSnapshot({ cacheKey, ttlMs, staleIfErrorTtlMs, forceRefresh, fetcher: fetchProfileDashboardSnapshot, }); - if (!forceRefresh && snapshot.meta.cacheStatus === "hit" && hasActiveProfileWork(snapshot.data)) { - snapshot = await getOrCreateAdminSnapshot({ - cacheKey, - ttlMs, - staleIfErrorTtlMs, - forceRefresh: true, - fetcher: fetchProfileDashboardSnapshot, - }); - } const dashboardFreshness: Record = snapshot.data.dashboard_freshness && typeof snapshot.data.dashboard_freshness === "object" diff --git a/apps/web/src/app/auth/finish/page.tsx b/apps/web/src/app/auth/finish/page.tsx index 3be4fb77..c42b4252 100644 --- a/apps/web/src/app/auth/finish/page.tsx +++ b/apps/web/src/app/auth/finish/page.tsx @@ -15,6 +15,9 @@ import Image from "next/image"; import MultiSelectPills from "@/components/survey/MultiSelectPills"; type FieldErrors = Partial>; +type ShowListResponse = { + shows?: Array<{ name?: unknown }>; +}; const FINISH_SHOW_REQUESTS_STORAGE_KEY = "finish_show_requests"; @@ -35,6 +38,7 @@ function FinishProfileContent() { const [state, setState] = useState(""); const [errors, setErrors] = useState({}); const [showsLoading, setShowsLoading] = useState(true); + const [apiShows, setApiShows] = useState([]); const [showRequestOpen, setShowRequestOpen] = useState(false); const [showRequestInput, setShowRequestInput] = useState(""); const [showRequests, setShowRequests] = useState([]); @@ -45,7 +49,11 @@ function FinishProfileContent() { try { const response = await fetch("/api/shows/list"); if (response.ok) { - await response.json(); + const data = (await response.json().catch(() => ({}))) as ShowListResponse; + const names = parseShows( + (data.shows ?? []).flatMap((show) => (typeof show.name === "string" ? [show.name] : [])), + ); + setApiShows(names); } } catch (error) { console.error("Failed to fetch shows:", error); @@ -115,6 +123,7 @@ function FinishProfileContent() { }, [router]); const selectedShows = useMemo(() => Object.keys(showSelections).filter((s) => showSelections[s]), [showSelections]); + const showOptions = apiShows.length > 0 ? apiShows : ALL_SHOWS; // Calculate max date for birthday (enforce 13+ age requirement from validation) const maxDate = useMemo(() => { @@ -505,7 +514,7 @@ function FinishProfileContent() { ) : ( ({ + items={showOptions.map((name, index) => ({ id: name, label: getDisplayName(name), color: showColors[index % showColors.length], diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index eec2afe5..5093d203 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { Analytics } from "@vercel/analytics/next"; +import { SpeedInsights } from "@vercel/speed-insights/next"; import { Geist, Inter, Playfair_Display } from "next/font/google"; import "./globals.css"; import "./side-menu.css"; @@ -63,6 +65,8 @@ export default async function RootLayout({ {children} + + diff --git a/apps/web/src/app/social/[platform]/[handle]/catalog/alt-1/page.tsx b/apps/web/src/app/social/[platform]/[handle]/catalog/alt-1/page.tsx new file mode 100644 index 00000000..926f4736 --- /dev/null +++ b/apps/web/src/app/social/[platform]/[handle]/catalog/alt-1/page.tsx @@ -0,0 +1,32 @@ +import { notFound, redirect } from "next/navigation"; +import { InstagramCatalogBackfillMockupView } from "@/components/admin/instagram/InstagramCatalogBackfillMockupView"; +import { + SOCIAL_ACCOUNT_CATALOG_ENABLED_PLATFORMS, + type SocialPlatformSlug, +} from "@/lib/admin/social-account-profile"; +import { resolveSocialAccountProfileRoute } from "@/lib/admin/social-account-profile-route"; + +type PageProps = { + params: Promise<{ platform: string; handle: string }>; +}; + +export default async function SocialAccountProfileCatalogAltOnePage({ params }: PageProps) { + const resolved = resolveSocialAccountProfileRoute(await params, { + tab: "catalog", + supportedPlatforms: SOCIAL_ACCOUNT_CATALOG_ENABLED_PLATFORMS, + }); + if (!resolved) { + notFound(); + } + if (resolved.requiresRedirect) { + redirect(`${resolved.canonicalUrl}/alt-1`); + } + return ( + + ); +} diff --git a/apps/web/src/components/admin/AdminGlobalHeader.tsx b/apps/web/src/components/admin/AdminGlobalHeader.tsx index f99a7a80..39e5e652 100644 --- a/apps/web/src/components/admin/AdminGlobalHeader.tsx +++ b/apps/web/src/components/admin/AdminGlobalHeader.tsx @@ -12,7 +12,7 @@ import { ADMIN_NAV_ITEMS } from "@/lib/admin/admin-navigation"; import { ADMIN_ROOT_PATH } from "@/lib/admin/admin-route-paths"; import { isPortfulPortlessAdminFallback, - PORTLESS_ADMIN_ORIGIN, + PORTLESS_ADMIN_DASHBOARD_URL, } from "@/lib/admin/admin-url-defaults"; import { buildTypographyDataAttributes } from "@/lib/typography/runtime"; import { @@ -49,10 +49,10 @@ export default function AdminGlobalHeader({ children, bodyClassName = "px-6 py-6 ) { return; } - const cleanUrl = `${PORTLESS_ADMIN_ORIGIN}${window.location.pathname}${window.location.search}${window.location.hash}`; - console.warn("[admin-url] Portful admin fallback detected. Use the Portless admin URL instead.", { + console.warn("[admin-url] Portful admin fallback detected. Use the clean Portless admin root instead.", { + clean_admin_url: PORTLESS_ADMIN_DASHBOARD_URL, + current_route: window.location.href, current_url: window.location.href, - clean_admin_url: cleanUrl, }); }, []); diff --git a/apps/web/src/components/admin/SocialAccountProfileHashtagTimelineChart.tsx b/apps/web/src/components/admin/SocialAccountProfileHashtagTimelineChart.tsx index 64295260..4736dd76 100644 --- a/apps/web/src/components/admin/SocialAccountProfileHashtagTimelineChart.tsx +++ b/apps/web/src/components/admin/SocialAccountProfileHashtagTimelineChart.tsx @@ -112,25 +112,30 @@ const firstVisiblePoint = ( return series.points.find((point) => point.in_top_ten) ?? null; }; +const formatDroppedOutRailLabel = (rank: number): string => `Dropped out (${rank}+)`; + export default function SocialAccountProfileHashtagTimelineChart({ timeline, loading = false, error }: Props) { const chartModel = useMemo(() => { if (!timeline || timeline.years.length === 0) return null; + const topRankLimit = Math.max(1, Math.floor(timeline.top_rank_limit || 10)); + const offChartRank = Math.max(topRankLimit + 1, Math.floor(timeline.off_chart_rank || topRankLimit + 1)); const leftRail = 78; const rightRail = 152; const topRail = 52; - const bottomRail = 68; + const bottomRail = 90; const columnGap = Math.max(130, Math.round(760 / Math.max(timeline.years.length, 2))); const width = leftRail + rightRail + Math.max(columnGap * Math.max(timeline.years.length - 1, 1), 520); - const height = topRail + bottomRail + 10 * 42; + const height = topRail + bottomRail + (topRankLimit + 1) * 42; const plotWidth = width - leftRail - rightRail; const plotHeight = height - topRail - bottomRail; - const rankStep = plotHeight / 9; + const rankStep = plotHeight / topRankLimit; const xByOrder = new Map(); for (const year of timeline.years) { const denominator = Math.max(timeline.years.length - 1, 1); xByOrder.set(year.order, leftRail + ((year.order - 1) / denominator) * plotWidth); } - const yForRank = (rank: number) => topRail + (clamp(rank, 1, 10) - 1) * rankStep; + const yForRank = (rank: number) => + topRail + (clamp(rank, 1, offChartRank) - 1) * rankStep; return { leftRail, rightRail, @@ -141,6 +146,8 @@ export default function SocialAccountProfileHashtagTimelineChart({ timeline, loa plotWidth, plotHeight, rankStep, + topRankLimit, + offChartRank, xByOrder, yForRank, }; @@ -224,7 +231,7 @@ export default function SocialAccountProfileHashtagTimelineChart({ timeline, loa fill="url(#timeline-surface)" /> - {Array.from({ length: 10 }, (_, index) => { + {Array.from({ length: chartModel.topRankLimit }, (_, index) => { const rank = index + 1; const y = chartModel.yForRank(rank); return ( @@ -254,6 +261,29 @@ export default function SocialAccountProfileHashtagTimelineChart({ timeline, loa ); })} + + + + {formatDroppedOutRailLabel(chartModel.offChartRank)} + + + {timeline?.years.map((year) => { const x = chartModel.xByOrder.get(year.order) ?? 0; return ( @@ -400,7 +430,8 @@ export default function SocialAccountProfileHashtagTimelineChart({ timeline, loa

Rank 1 sits at the top. Faded arcs show the full span between a hashtag's first and last top-ten year, - including the years when it slipped outside the chart. + including the years when it slipped outside the chart. {formatDroppedOutRailLabel(chartModel.offChartRank)} + {" "}uses a separate rail below rank {chartModel.topRankLimit}.

{timeline?.series.length ?? 0} tracked hashtags diff --git a/apps/web/src/components/admin/SocialAccountProfilePage.tsx b/apps/web/src/components/admin/SocialAccountProfilePage.tsx index d138dc38..ec071178 100644 --- a/apps/web/src/components/admin/SocialAccountProfilePage.tsx +++ b/apps/web/src/components/admin/SocialAccountProfilePage.tsx @@ -47,6 +47,7 @@ import { type SocialAccountCatalogRunProgressSnapshot, type SocialAccountCatalogRunProgressStage, type SocialAccountCatalogStageGraphNode, + type SocialAccountCatalogCommentsStreaming, type SocialAccountDashboardFreshness, type SocialAccountOperationalAlert, type SocialAccountProfileHashtag, @@ -58,10 +59,12 @@ import { type SocialAccountProfileSummaryDetail, type SocialAccountProfileSummary, type SocialAccountProfileTab, + type SocialHashtagAssignmentBackfillConflict, type SocialPlatformSlug, type SocialAccountProfileCollaboratorTagAggregate, type SocialProfileCookieHealth, type SocialProfileCookieRefreshResult, + type SocialAccountCommentsGapPost, type SocialAccountCommentsRunProgress, type SocialAccountCommentsShardProgress, SOCIAL_ACCOUNT_CATALOG_DETAIL_ENABLED_PLATFORMS, @@ -93,6 +96,50 @@ type Props = { activeTab: SocialAccountProfileTab; }; +type InstagramCompletionLaneSummary = { + finished: number; + in_progress: number; + not_started: number; +}; + +type InstagramCompletionSummary = { + year: number; + total_posts: number; + total_reported_comments: number; + saved_comments: number; + missing_comments: number; + accounted_comments: number; + comment_gap_posts?: SocialAccountCommentsGapPost[] | null; + lanes: { + comments: InstagramCompletionLaneSummary; + details: InstagramCompletionLaneSummary; + media: InstagramCompletionLaneSummary; + }; +}; + +type InstagramCommentsGapDisplayRow = { + key: string; + label: string; + missingCommentGap: number; + savedCommentCount: number | null; + reportedCommentCount: number | null; + status: string | null; + reason: string | null; + phase: string | null; + hasTopLevelCursor: boolean; + replyResumeCount: number | null; +}; + +type InstagramCommentsStreamingBanner = { + stateLabel: string; + detail: string; + runLabel: string | null; + nextActionLabel: string; + nextActionDetail: string; + metricItems: string[]; + historyItems: string[]; +}; + const INSTAGRAM_AUTH_REFRESH_CONFIRMATION = "I UNDERSTAND INSTAGRAM AUTH RISK"; const INSTAGRAM_AUTH_REFRESH_WARNING = "Manual Instagram auth can surface CAPTCHA, verification code, checkpoint, or account-lock prompts. Complete those steps yourself before confirming a validated-cookie sync."; @@ -110,6 +157,19 @@ const nowMs = (): number => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); + +const getCurrentUtcYear = (): number => new Date().getUTCFullYear(); + +const readBackfillMonthYear = (value: string | null | undefined): number | null => { + const match = /^(\d{4})-(\d{2})$/.exec(String(value ?? "").trim()); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + if (!Number.isInteger(year) || year < 2000 || year > 2100) return null; + if (!Number.isInteger(month) || month < 1 || month > 12) return null; + return year; +}; + export const getCatalogRepairAuthEndpointSegment = (repairAction: string | null | undefined): "manual-auth" | "repair-auth" => repairAction === "repair_instagram_auth" ? "manual-auth" : "repair-auth"; @@ -136,6 +196,34 @@ type CatalogPostsResponse = { }; }; +const normalizeCatalogPostsResponse = ( + data: Partial | null | undefined, + fallbackPage: number, + fallbackPageSize: number, +): CatalogPostsResponse => { + const items = Array.isArray(data?.items) ? data.items : []; + const pagination = data?.pagination; + const rawPage = Number(pagination?.page); + const rawPageSize = Number(pagination?.page_size); + const rawTotal = Number(pagination?.total); + const rawTotalPages = Number(pagination?.total_pages); + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.floor(rawPageSize) : fallbackPageSize; + const total = Number.isFinite(rawTotal) && rawTotal >= 0 ? Math.floor(rawTotal) : items.length; + const totalPages = + Number.isFinite(rawTotalPages) && rawTotalPages > 0 ? Math.floor(rawTotalPages) : Math.max(1, Math.ceil(total / pageSize)); + const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : fallbackPage; + + return { + items, + pagination: { + page, + page_size: pageSize, + total, + total_pages: totalPages, + }, + }; +}; + const formatPostsSortMode = (mode: string | null | undefined): string => { switch (mode) { case "persisted_rollup": @@ -149,8 +237,15 @@ const formatPostsSortMode = (mode: string | null | undefined): string => { } }; +type HashtagAssignmentStatus = "all" | "assigned" | "unassigned"; + type HashtagsResponse = { items: SocialAccountProfileHashtag[]; + assignment_status?: HashtagAssignmentStatus; +}; + +type HashtagConflictsResponse = { + items: SocialHashtagAssignmentBackfillConflict[]; }; type HashtagTimelineResponse = SocialAccountProfileHashtagTimeline & { @@ -196,6 +291,12 @@ type PendingCatalogAction = { requestBody: CatalogBackfillRequest | CatalogSyncRecentRequest | CatalogSyncNewerRequest; }; +type PendingInstagramApply = { + requestBody: CatalogBackfillRequest; + applyRunId: string; + requiredConfirmation: string; +}; + type BackfillTaskOption = { value: CatalogBackfillSelectedTask; label: string; @@ -384,6 +485,7 @@ const INTEGER_FORMATTER = new Intl.NumberFormat("en-US"); const ACTIVE_CATALOG_RUN_STATUSES = new Set(["queued", "pending", "retrying", "running", "cancelling"]); const ACTIVE_CATALOG_RECOVERY_STATUSES = new Set(["queued", "running", "fallback_enqueued", "blocked"]); const TERMINAL_CATALOG_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]); +const HISTORICAL_ATTACHED_LANE_PARENT_STATUSES = new Set(["failed", "cancelled"]); const COMMENTS_PROGRESS_POLL_INTERVAL_MS = 5_000; const TWITTER_BACKFILL_LOOKBACK_DAYS = 365; const TIKTOK_EMPTY_FIRST_PAGE_ERROR_CODE = "tiktok_discovery_empty_first_page"; @@ -422,6 +524,7 @@ const INSTAGRAM_BACKFILL_TASK_OPTIONS: ReadonlyArray = [ const INSTAGRAM_BACKFILL_DEFAULT_SELECTED_TASKS: CatalogBackfillSelectedTask[] = [ "post_details", "comments", + "media", ]; const INSTAGRAM_BACKFILL_DETAIL_WORKER_OPTIONS = [1, 2, 4, 6, 8, 12] as const; const INSTAGRAM_BACKFILL_COMMENTS_WORKER_OPTIONS = [1, 2, 4, 6, 8] as const; @@ -468,6 +571,11 @@ const HASHTAG_WINDOW_OPTIONS = [ { value: "30d", label: "This Month" }, { value: "365d", label: "This Year" }, ] as const; +const HASHTAG_ASSIGNMENT_STATUS_OPTIONS: ReadonlyArray<{ value: HashtagAssignmentStatus; label: string }> = [ + { value: "all", label: "All Hashtags" }, + { value: "assigned", label: "Assigned" }, + { value: "unassigned", label: "Unassigned" }, +]; const CATALOG_STAGE_SORT_ORDER: Record = { posts: 0, comments: 1, @@ -481,6 +589,22 @@ const CATALOG_STAGE_SORT_ORDER: Record = { other: 99, }; +const getSupportedSocialAccountTab = ( + platform: SocialPlatformSlug | string, + preferredTab: SocialAccountProfileTab, +): SocialAccountProfileTab => { + const normalizedPlatform = String(platform || "").trim().toLowerCase() as SocialPlatformSlug; + const supportsCatalog = SOCIAL_ACCOUNT_CATALOG_ENABLED_PLATFORMS.includes(normalizedPlatform); + const supportsComments = SOCIAL_ACCOUNT_COMMENTS_ENABLED_PLATFORMS.includes(normalizedPlatform); + const supportsSocialBlade = SOCIAL_ACCOUNT_SOCIALBLADE_ENABLED_PLATFORMS.includes(normalizedPlatform); + + if (preferredTab === "posts") return supportsCatalog ? "catalog" : "posts"; + if (preferredTab === "catalog" && !supportsCatalog) return "stats"; + if (preferredTab === "comments" && !supportsComments) return "stats"; + if (preferredTab === "socialblade" && !supportsSocialBlade) return "stats"; + return preferredTab; +}; + const buildCatalogProgressRunStorageKey = (platform: string, handle: string) => `${CATALOG_PROGRESS_RUN_STORAGE_PREFIX}:${platform.trim().toLowerCase()}:${handle.trim().toLowerCase()}`; @@ -549,6 +673,11 @@ const readFiniteNumber = (value: unknown): number | null => { return Number.isFinite(numeric) ? numeric : null; }; +const readNonNegativeInteger = (value: unknown): number | null => { + const numeric = readFiniteNumber(value); + return numeric === null ? null : Math.max(0, Math.trunc(numeric)); +}; + const asRecord = (value: unknown): Record | null => { if (!value || typeof value !== "object" || Array.isArray(value)) return null; return value as Record; @@ -658,6 +787,8 @@ const monthInputToWindowBoundaryIso = (value: string, boundary: "start" | "end") return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); }; +const buildInstagramApplyConfirmation = (runId: string): string => `APPLY INSTAGRAM 2025 BACKFILL ${runId}`; + const formatMonthYear = (value: string): string => { const match = /^(\d{4})-(\d{2})$/.exec(String(value || "").trim()); return match ? `${match[2]}-${match[1]}` : value; @@ -689,6 +820,17 @@ const formatDashboardFreshnessAge = (ageSeconds: number | null | undefined): str return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; }; +const formatRouteDuration = (durationMs: number | null | undefined): string => { + if (durationMs == null || !Number.isFinite(Number(durationMs))) { + return "unknown"; + } + const normalizedMs = Math.max(0, Math.round(Number(durationMs))); + if (normalizedMs < 1000) { + return `${normalizedMs} ms`; + } + return `${(normalizedMs / 1000).toFixed(normalizedMs < 10_000 ? 1 : 0)} s`; +}; + export const defaultLocalCatalogCommandSelectedTasks = ( platform: SocialPlatformSlug, action: "backfill" | "fill_missing_posts", @@ -844,6 +986,206 @@ const formatDiagnosticToken = (value?: string | null): string => { return normalized.replace(/_/g, " "); }; +const COMMENTS_SKIP_REASON_LABELS: Record = { + comments_not_selected: "Comments lane not selected", + posts_auth_blocked: "Posts auth blocked", + no_commentable_targets: "No commentable targets", + authenticated_comments_not_requested: "Authenticated comments not requested", + comments_running_or_complete: "Comments already running or complete", +}; + +const formatCommentsSkipReasonLabel = (value?: string | null): string => { + const normalized = String(value || "").trim().toLowerCase(); + if (!normalized) return ""; + return COMMENTS_SKIP_REASON_LABELS[normalized] ?? formatDiagnosticToken(normalized); +}; + +const normalizeInstagramCommentsGapDisplayRow = (value: unknown): InstagramCommentsGapDisplayRow | null => { + const record = asRecord(value); + if (!record) return null; + const key = + readString(record.source_id) || + readString(record.shortcode) || + readString(record.post_id) || + readString(record.post_url); + const label = + readString(record.shortcode) || + readString(record.source_id) || + readString(record.post_id) || + readString(record.post_url); + const savedCommentCount = readNonNegativeInteger(record.saved_comment_count); + const reportedCommentCount = + readNonNegativeInteger(record.reported_comment_count) ?? + readNonNegativeInteger(record.observed_comment_count); + const missingCommentGap = + readNonNegativeInteger(record.missing_comment_gap) ?? + (reportedCommentCount !== null ? Math.max(reportedCommentCount - (savedCommentCount ?? 0), 0) : null); + if (!key || !label || missingCommentGap === null || missingCommentGap <= 0) { + return null; + } + return { + key, + label, + missingCommentGap, + savedCommentCount, + reportedCommentCount, + status: readString(record.status), + reason: + readString(record.latest_reason) || + readString(record.fetch_reason) || + readString(record.latest_stop_reason) || + readString(record.cursor_stop_reason), + phase: readString(record.current_phase), + hasTopLevelCursor: record.has_top_level_cursor === true, + replyResumeCount: readNonNegativeInteger(record.reply_resume_count), + }; +}; + +const buildInstagramCommentsGapDisplayRows = ( + ...sources: Array +): InstagramCommentsGapDisplayRow[] => { + const rows = new Map(); + for (const source of sources) { + if (!Array.isArray(source)) continue; + for (const item of source) { + const row = normalizeInstagramCommentsGapDisplayRow(item); + if (!row || rows.has(row.key)) continue; + rows.set(row.key, row); + } + } + return Array.from(rows.values()) + .sort((left, right) => { + if (right.missingCommentGap !== left.missingCommentGap) { + return right.missingCommentGap - left.missingCommentGap; + } + return left.label.localeCompare(right.label); + }) + .slice(0, 8); +}; + +const buildInstagramCommentsStreamingBanner = ( + streaming: SocialAccountCatalogCommentsStreaming | null | undefined, + fallbackRunId?: string | null, +): InstagramCommentsStreamingBanner | null => { + if (!streaming?.enabled) return null; + const state = readString(streaming.state) || "started"; + const commentsRunId = readString(streaming.comments_run_id) || readString(fallbackRunId); + const enqueued = readNonNegativeInteger(streaming.targets_enqueued); + const seen = readNonNegativeInteger(streaming.targets_seen); + const skippedDuplicate = readNonNegativeInteger(streaming.targets_skipped_duplicate); + const appendFailures = readNonNegativeInteger(streaming.append_failures); + const workerCount = readNonNegativeInteger(streaming.worker_count); + const lastLag = readNonNegativeInteger(streaming.last_enqueue_lag_ms); + const maxLag = readNonNegativeInteger(streaming.max_enqueue_lag_ms); + const averageLag = readFiniteNumber(streaming.average_enqueue_lag_ms); + const attemptCount = readNonNegativeInteger(streaming.enqueue_attempt_count); + const lastBatchCount = readNonNegativeInteger(streaming.last_batch_source_ids_count); + const source = readString(streaming.source); + const sourceLabel = source ? formatDiagnosticToken(source) : "catalog batch persist"; + const nextAction = asRecord(streaming.next_action); + const nextActionLabel = readString(nextAction?.label) || "Waiting for saved catalog batch"; + const nextActionDetail = + readString(nextAction?.detail) || + "The comments lane will start or append work as soon as the next catalog batch is durably saved."; + const historyItems = Array.isArray(streaming.history) + ? streaming.history + .slice(-4) + .reverse() + .map((item) => { + const entry = asRecord(item); + if (!entry) return null; + const entryState = readString(entry.state); + const entryAt = readString(entry.recorded_at); + const entrySeen = readNonNegativeInteger(entry.enqueue_targets_seen); + const entryEnqueued = readNonNegativeInteger(entry.enqueue_targets_enqueued); + const entryLag = readNonNegativeInteger(entry.last_enqueue_lag_ms); + return [ + entryState ? formatRunStatusLabel(entryState) : null, + entrySeen !== null ? `${formatInteger(entrySeen)} seen` : null, + entryEnqueued !== null ? `${formatInteger(entryEnqueued)} enqueued` : null, + entryLag !== null ? `${formatInteger(entryLag)} ms` : null, + entryAt ? formatDateTime(entryAt) : null, + ] + .filter(Boolean) + .join(" · "); + }) + .filter((item): item is string => Boolean(item)) + : []; + const metricItems = [ + enqueued !== null ? `${formatInteger(enqueued)} posts enqueued` : null, + seen !== null ? `${formatInteger(seen)} posts seen` : null, + skippedDuplicate !== null && skippedDuplicate > 0 ? `${formatInteger(skippedDuplicate)} skipped as duplicates` : null, + appendFailures !== null && appendFailures > 0 ? `${formatInteger(appendFailures)} append failures` : null, + workerCount !== null && workerCount > 0 ? `${formatInteger(workerCount)} comment workers` : null, + lastBatchCount !== null && lastBatchCount > 0 ? `${formatInteger(lastBatchCount)} posts in latest batch` : null, + lastLag !== null ? `${formatInteger(lastLag)} ms enqueue lag` : null, + maxLag !== null ? `${formatInteger(maxLag)} ms max lag` : null, + averageLag !== null ? `${averageLag.toFixed(1)} ms average lag` : null, + attemptCount !== null && attemptCount > 0 ? `${formatInteger(attemptCount)} enqueue attempts` : null, + ].filter((item): item is string => Boolean(item)); + return { + stateLabel: formatRunStatusLabel(state), + runLabel: commentsRunId ? `run ${shortRunId(commentsRunId)}` : null, + detail: `Saved catalog batches are feeding comments immediately through ${sourceLabel}. Public-first recovery remains the default; account-backed follow-up stays last resort.`, + nextActionLabel, + nextActionDetail, + metricItems, + historyItems, + }; +}; + +const getCommentsShardAttentionRecommendation = ( + progress: SocialAccountCommentsRunProgress | null | undefined, + issueReasons: string[], +): string => { + const action = String( + progress?.recommended_next_action || progress?.operator_next_action || progress?.recommended_action || "", + ) + .trim() + .toLowerCase(); + const reasonText = issueReasons.join(" ").toLowerCase(); + const retryProgress = progress?.retry_progress as JsonRecord | undefined; + const publicRecoveryCount = + readFiniteNumber((progress as JsonRecord | undefined)?.public_comments_recovery_pending_target_count) ?? + readFiniteNumber(retryProgress?.public_comments_recovery_pending_target_count) ?? + 0; + const publicApprovalCount = + readFiniteNumber((progress as JsonRecord | undefined)?.public_comments_approval_required_target_count) ?? + readFiniteNumber(retryProgress?.public_comments_approval_required_target_count) ?? + 0; + if ( + action === "start_comments_public_recovery" || + publicRecoveryCount > 0 || + reasonText.includes("public recovery pending") + ) { + return "Public scraping still has recoverable gaps; queue public recovery before considering any account-backed fallback."; + } + if ( + action === "approve_comments_auth_or_proxy_fallback" || + publicApprovalCount > 0 || + reasonText.includes("public requires approval") || + reasonText.includes("requires approval") + ) { + return "Some remaining comments need an approved account-backed follow-up; public recovery is no longer the next step for those posts."; + } + if (action === "retry_cursor_deadline_targets" || reasonText.includes("pagination deadline")) { + return "Cursor deadline gaps are retryable; start Incomplete Fill to auto-queue cursor continuation jobs, or use Retry Cursor Targets for only those posts."; + } + if (action === "retry_network_stopped_targets") { + return "Network-stopped targets are retryable; queue cursor recovery for those posts instead of cancelling the whole run."; + } + if (action === "wait_for_retry_jobs") { + return "Retry jobs are already active; let them continue while saved-count movement is still happening."; + } + if (action === "mark_stale_jobs_terminal_or_retry") { + return "Only cancel or recover shards with stale heartbeats; do not cancel the whole comments run."; + } + if (action === "retry_largest_gaps" || action === "retry_incomplete_targets") { + return "Continue with Incomplete Fill so unfinished posts are targeted without rerunning completed posts."; + } + return "Cancel only a shard that is stale or no longer moving; failed or retrying shards do not mean the whole comments run is dead."; +}; + const normalizeCatalogActionScope = (value?: string | null): SocialAccountCatalogActionScope | null => { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) return null; @@ -939,7 +1281,7 @@ const formatPhaseProgress = (progress?: SocialAccountCatalogRunProgressSnapshot[ return parts.filter(Boolean).join(" · ") || null; }; -const buildCatalogProgressDiagnosticRows = ( +export const buildCatalogProgressDiagnosticRows = ( progress?: SocialAccountCatalogRunProgressSnapshot | null, summary?: SocialAccountProfileSummary | null, ): CatalogProgressDiagnosticRow[] => { @@ -960,6 +1302,63 @@ const buildCatalogProgressDiagnosticRows = ( ]); const listingStatus = String(progress?.listing_progress?.status || "").trim(); const selectedTasks = new Set([...(progress?.selected_tasks ?? []), ...(progress?.effective_selected_tasks ?? [])]); + const budgetDecision = progress?.budget_decision as JsonRecord | null | undefined; + const budgetLimits = budgetDecision?.limits as JsonRecord | null | undefined; + const budgetRunbook = + (progress?.runbook_state as JsonRecord | null | undefined) ?? + (budgetDecision?.runbook_state as JsonRecord | null | undefined); + const budgetState = String(budgetDecision?.state || "").trim().toLowerCase(); + const effectiveBudgetCap = getNumberFromRecord(budgetLimits, [ + "effective_max_concurrent_jobs", + "normal_max_concurrent_jobs", + ]); + const requestedBudgetCap = getNumberFromRecord(budgetLimits, [ + "requested_max_concurrent_jobs", + "healthy_max_concurrent_jobs", + ]); + const bindingBudgetCap = + getNumberFromRecord(budgetLimits, ["live_apply_binding_cap", "comments_job_concurrency_limit", "binding_cap"]) ?? + getNumberFromRecord(budgetRunbook, ["binding_cap", "current_comments_cap"]); + const canaryMetadata = budgetRunbook?.cap4_canary as JsonRecord | null | undefined; + const canaryCap = + getNumberFromRecord(budgetLimits, ["cap4_canary_max_concurrent_jobs"]) ?? + getNumberFromRecord(budgetRunbook, ["speed_canary_cap"]) ?? + getNumberFromRecord(canaryMetadata, ["cap"]); + const canaryMinimumJobs = + getNumberFromRecord(budgetRunbook, ["minimum_completed_comments_jobs"]) ?? + getNumberFromRecord(canaryMetadata, ["minimum_completed_comments_jobs"]); + const canaryActive = Boolean( + progress?.enable_cap4_canary || + budgetLimits?.cap4_canary_active || + canaryMetadata?.active, + ); + if (budgetState || effectiveBudgetCap != null || bindingBudgetCap != null) { + const budgetParts = [ + budgetState ? formatDiagnosticToken(budgetState) : null, + effectiveBudgetCap != null ? `effective cap ${formatInteger(effectiveBudgetCap)}` : null, + bindingBudgetCap != null ? `binding cap ${formatInteger(bindingBudgetCap)}` : null, + requestedBudgetCap != null && requestedBudgetCap !== effectiveBudgetCap + ? `requested ${formatInteger(requestedBudgetCap)}` + : null, + ].filter(Boolean); + rows.push({ + key: "budget-decision", + label: "Budget", + value: budgetParts.join(" · "), + detail: "Budget is checked before catalog jobs launch; paused or identity-blocked lanes do not dispatch new catalog work.", + }); + } + if (canaryCap != null) { + rows.push({ + key: "cap4-canary", + label: "Cap 4 Canary", + value: canaryActive ? `enabled at cap ${formatInteger(canaryCap)}` : `available at cap ${formatInteger(canaryCap)}`, + detail: + canaryMinimumJobs != null + ? `Trust the verdict after at least ${formatInteger(canaryMinimumJobs)} completed comments jobs.` + : "Roll back to cap 2 on auth, proxy, 429, or zero-write failures.", + }); + } const hasDetailRefreshSignal = Boolean(detailsLabel) || getNumberFromRecord(progress?.post_progress, ["completed_posts", "matched_posts"]) != null || @@ -990,16 +1389,31 @@ const buildCatalogProgressDiagnosticRows = ( } if (selectedTasks.has("post_details")) { + const appliedDetailWorkerCount = getNumberFromRecord(progress, ["details_refresh_worker_count"]); + const requestedDetailWorkerCount = getNumberFromRecord(progress, ["requested_details_worker_count"]); + const liveApplyBindingCap = getNumberFromRecord(progress, ["live_apply_binding_cap"]); + const workerCapNote = readString(progress?.worker_cap_note); const detailWorkerCount = + appliedDetailWorkerCount ?? getNumberFromRecord(progress?.worker_runtime, ["runner_count"]) ?? - getNumberFromRecord(progress, ["details_refresh_shard_count", "details_refresh_worker_count"]); + getNumberFromRecord(progress, ["detail_worker_count", "details_refresh_shard_count"]); const detailRunnerStrategy = String(progress?.worker_runtime?.runner_strategy || "").trim().toLowerCase(); if (detailWorkerCount != null) { + const showRequestedVsApplied = + requestedDetailWorkerCount != null && requestedDetailWorkerCount !== detailWorkerCount; + const workerValue = showRequestedVsApplied + ? `${formatInteger(detailWorkerCount)} applied · ${formatInteger(requestedDetailWorkerCount)} requested` + : `${formatInteger(detailWorkerCount)} ${detailWorkerCount === 1 ? "worker" : "workers"}`; + const detailParts = [ + detailRunnerStrategy ? `Strategy: ${formatDiagnosticToken(detailRunnerStrategy)}.` : null, + liveApplyBindingCap != null ? `Binding cap ${formatInteger(liveApplyBindingCap)}.` : null, + workerCapNote, + ].filter(Boolean); rows.push({ key: "detail-worker-count", label: "Detail Worker Count", - value: `${formatInteger(detailWorkerCount)} ${detailWorkerCount === 1 ? "worker" : "workers"}`, - detail: detailRunnerStrategy ? `Strategy: ${formatDiagnosticToken(detailRunnerStrategy)}.` : null, + value: workerValue, + detail: detailParts.length > 0 ? detailParts.join(" ") : null, }); } if (detailWorkerCount === 1 && detailRunnerStrategy === "single_runner") { @@ -1016,6 +1430,7 @@ const buildCatalogProgressDiagnosticRows = ( const targetReadiness = progress?.target_readiness as JsonRecord | null | undefined; const commentsPreview = targetReadiness?.comments_preview; const commentsWorkerCount = + getNumberFromRecord(progress, ["comments_worker_count"]) ?? getNumberFromRecord(commentsPreview, ["comments_shard_count", "recommended_comments_shard_count"]) ?? getNumberFromRecord(targetReadiness, ["comments_shard_count", "recommended_comments_shard_count"]); if (commentsWorkerCount != null) { @@ -1028,6 +1443,22 @@ const buildCatalogProgressDiagnosticRows = ( } } + const commentsSkipReason = readString(progress?.comments_skip_reason); + if (commentsSkipReason) { + const commentsSkipDetail = readString(progress?.comments_skip_detail); + const commentsOperatorAction = readString(progress?.comments_operator_action); + const skipDetailParts = [ + commentsSkipDetail, + commentsOperatorAction ? `Operator action: ${commentsOperatorAction}` : null, + ].filter(Boolean); + rows.push({ + key: "comments-skip-reason", + label: "Comments Skipped", + value: formatCommentsSkipReasonLabel(commentsSkipReason), + detail: skipDetailParts.length > 0 ? skipDetailParts.join(" ") : null, + }); + } + const coverage = progress?.rich_field_coverage ?? progress?.field_coverage ?? null; if (coverage && typeof coverage === "object") { const preferredKeys = [ @@ -1195,12 +1626,14 @@ const formatHashtagWindowLabel = (value: (typeof HASHTAG_WINDOW_OPTIONS)[number] export const shouldUseSummaryTopHashtagsPreview = (options: { activeTab: SocialAccountProfileTab; hashtagWindow: (typeof HASHTAG_WINDOW_OPTIONS)[number]["value"]; + hashtagAssignmentStatus: HashtagAssignmentStatus; summaryTopHashtags: ReadonlyArray | null | undefined; hasLoadedExactWindow: boolean; }): boolean => { return ( options.activeTab === "stats" && options.hashtagWindow === "all" && + options.hashtagAssignmentStatus === "all" && !options.hasLoadedExactWindow && (options.summaryTopHashtags?.length ?? 0) > 0 ); @@ -1446,6 +1879,15 @@ const isBackendPressureError = (error: unknown): error is SocialAccountRequestEr ); }; +const formatCatalogGalleryErrorMessage = (error: unknown): string => { + const requestError = toSocialAccountRequestError(error, "Failed to load social account catalog posts"); + const fallbackMessage = requestError.message || "Failed to load social account catalog posts"; + if (isBackendPressureError(requestError) || isTimeoutRequestError(requestError)) { + return "Catalog gallery temporarily unavailable. Saved post cards will reload when the gallery request succeeds."; + } + return fallbackMessage; +}; + const formatCatalogDiagnosticErrorMessage = ( label: "Freshness check" | "Gap analysis", error: SocialAccountRequestError | null, @@ -1499,7 +1941,7 @@ const formatSummaryRequestErrorMessage = (error: SocialAccountRequestError | nul return "Failed to load social account profile summary"; } if (isTimeoutRequestError(error)) { - return "Summary read timed out before completion. Retry in a moment."; + return "Account summary temporarily unavailable. Saved profile totals may be stale until the summary reloads."; } return (error as Error).message || "Failed to load social account profile summary"; }; @@ -1555,15 +1997,25 @@ const buildHashtagDraftAssignments = ( return Object.fromEntries( items.map((item) => [ item.hashtag, - item.assignments?.map((assignment) => ({ ...assignment })) ?? [], + item.assignments?.map((assignment) => normalizeHashtagAssignmentDraft(assignment)) ?? [], ]), ); }; +const normalizeHashtagAssignmentDraft = ( + assignment: SocialAccountProfileHashtagAssignment, +): SocialAccountProfileHashtagAssignment => { + return { + ...assignment, + assignment_scope: "global", + platform: null, + }; +}; + const cloneHashtagItems = (items: SocialAccountProfileHashtag[]): SocialAccountProfileHashtag[] => { return items.map((item) => ({ ...item, - assignments: item.assignments?.map((assignment) => ({ ...assignment })) ?? [], + assignments: item.assignments?.map((assignment) => normalizeHashtagAssignmentDraft(assignment)) ?? [], assigned_shows: item.assigned_shows?.map((show) => ({ ...show })) ?? [], observed_shows: item.observed_shows?.map((show) => ({ ...show })) ?? [], observed_seasons: item.observed_seasons?.map((season) => ({ ...season })) ?? [], @@ -1581,6 +2033,29 @@ const formatHashtagRequestErrorMessage = (error: SocialAccountRequestError | nul return (error as Error).message || "Failed to load social account profile hashtags"; }; +const formatHashtagConflictAction = (value: string | null | undefined): string => { + const normalized = String(value || "merged_global_show_set").trim().replaceAll("_", " "); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +}; + +const formatHashtagConflictLegacyAssignments = ( + assignments: SocialHashtagAssignmentBackfillConflict["legacy_assignments"] | null | undefined, +): string => { + const items = assignments ?? []; + if (items.length === 0) return "No legacy rows"; + return items + .slice(0, 3) + .map((assignment) => { + const platformLabel = assignment.platform + ? SOCIAL_ACCOUNT_PLATFORM_LABELS[assignment.platform as SocialPlatformSlug] ?? assignment.platform + : "Unknown"; + const handleLabel = assignment.account_handle ? `@${assignment.account_handle}` : "no handle"; + const showLabel = assignment.show_id ? `show ${assignment.show_id}` : "no show"; + return `${platformLabel} ${handleLabel} -> ${showLabel}`; + }) + .join("; "); +}; + const resolveCatalogGapAnalysisBackoffMs = ( error: SocialAccountRequestError | null, saturationAttempt: number, @@ -2195,6 +2670,25 @@ const shortRunId = (value?: string | null): string => { return normalized ? normalized.slice(0, 8) : "pending"; }; +const getCatalogRunIdentity = (run?: Pick | null): string => { + return String(run?.run_id || run?.job_id || "").trim(); +}; + +const dedupeCatalogRecentRuns = (runs: SocialAccountCatalogRun[]): SocialAccountCatalogRun[] => { + const seen = new Set(); + const deduped: SocialAccountCatalogRun[] = []; + for (const run of runs) { + const identity = getCatalogRunIdentity(run); + if (identity) { + const normalizedIdentity = identity.toLowerCase(); + if (seen.has(normalizedIdentity)) continue; + seen.add(normalizedIdentity); + } + deduped.push(run); + } + return deduped; +}; + const formatAttachedLaneSourceLabel = (value?: string | null): string | null => { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) return null; @@ -2242,6 +2736,59 @@ const shouldPreferAttachedFollowupLaneStatus = (value?: string | null): boolean return TERMINAL_CATALOG_RUN_STATUSES.has(normalized) || ACTIVE_CATALOG_RUN_STATUSES.has(normalized) || normalized === "blocked_auth"; }; +const normalizeHistoricalAttachedLaneStatus = ( + parentStatus?: string | null, + laneStatus?: string | null, +): string | null => { + const normalizedParentStatus = normalizeCatalogLaneStatus(parentStatus); + const normalizedLaneStatus = normalizeCatalogLaneStatus(laneStatus); + if (!normalizedParentStatus || !HISTORICAL_ATTACHED_LANE_PARENT_STATUSES.has(normalizedParentStatus)) { + return normalizedLaneStatus; + } + if ( + normalizedLaneStatus === "completed" || + normalizedLaneStatus === "failed" || + normalizedLaneStatus === "cancelled" + ) { + return normalizedLaneStatus; + } + return normalizedParentStatus === "cancelled" ? "cancelled" : "not_started"; +}; + +const selectedTaskForStageGraphLane = (key: string): CatalogBackfillSelectedTask | null => { + const normalized = String(key || "").trim().toLowerCase(); + if (normalized === "detail_refresh") return "post_details"; + if (normalized === "comments") return "comments"; + if (normalized === "media") return "media"; + return null; +}; + +const isCatalogLaneSelected = ( + progress: SocialAccountCatalogRunProgressSnapshot | null | undefined, + key: string, +): boolean => { + const task = selectedTaskForStageGraphLane(key); + if (!task) return false; + return new Set([ + ...(progress?.selected_tasks ?? []), + ...(progress?.effective_selected_tasks ?? []), + ]).has(task); +}; + +const fallbackSelectedStageGraphLaneStatus = ( + progress: SocialAccountCatalogRunProgressSnapshot | null | undefined, + key: string, +): string => { + const normalized = String(key || "").trim().toLowerCase(); + if (normalized === "detail_refresh") { + const detailStatus = normalizeCatalogLaneStatus(progress?.details_progress?.status); + if (detailStatus && detailStatus !== "skipped") return detailStatus; + const runStatus = normalizeCatalogLaneStatus(progress?.run_status); + if (runStatus === "completed") return "completed"; + } + return "pending"; +}; + const getStageGraphBlockedReason = (node?: SocialAccountCatalogStageGraphNode | null): string | null => { const reasons = [ node?.blocked_reason, @@ -2333,10 +2880,17 @@ const buildStageGraphLaneCards = ( const followupErrorDetail = followupErrorMessage ? `${followup && "retryable" in followup && followup.retryable ? "Retryable: " : ""}${followupErrorMessage}` : null; + const selectedLane = isCatalogLaneSelected(progress, key); + const normalizedNodeStatus = normalizeCatalogLaneStatus(nodeStatus); + const attachedStatus = shouldPreferAttachedFollowupLaneStatus(followupStatus) ? followupStatus : null; + const safeNodeStatus = + selectedLane && (!normalizedNodeStatus || normalizedNodeStatus === "skipped") + ? attachedStatus ?? fallbackStatus ?? fallbackSelectedStageGraphLaneStatus(progress, key) + : nodeStatus; lanes.push({ key, label: formatStageGraphLaneLabel(key), - status: shouldPreferAttachedFollowupLaneStatus(followupStatus) ? followupStatus : nodeStatus ?? fallbackStatus, + status: attachedStatus ?? safeNodeStatus ?? fallbackStatus, sourceLabel: formatAttachedLaneSourceLabel(followup?.source), detail: followupDetail || followupErrorDetail || String(node?.detail || "").trim() || fallbackDetail || null, blockedReason: getStageGraphBlockedReason(node), @@ -2431,12 +2985,14 @@ const buildCatalogLaneCards = (options: { ? `${commentsFollowup?.retryable ? "Retryable: " : ""}${followupErrorMessage}` : null; const normalizedCommentsFollowupStatus = normalizeCatalogLaneStatus(commentsFollowup?.status); - const commentsStatus = + const commentsStatus = normalizeHistoricalAttachedLaneStatus( + terminalRunStatus, terminalRunStatus && !commentsRunId && commentsFollowup?.source === "deferred_after_catalog" ? normalizedCommentsFollowupStatus === "failed" || normalizedCommentsFollowupStatus === "cancelled" ? normalizedCommentsFollowupStatus : terminalRunStatus - : commentsFollowup?.status ?? (commentsRunId ? "queued" : null); + : commentsFollowup?.status ?? commentsFollowup?.state ?? (commentsRunId ? "queued" : null), + ); cards.push({ key: "comments", label: "Comments", @@ -2455,13 +3011,15 @@ const buildCatalogLaneCards = (options: { const attachmentId = String(mediaFollowup?.attachment_id || "").trim() || null; const enqueuedCount = typeof mediaFollowup?.enqueued_job_count === "number" ? mediaFollowup.enqueued_job_count : null; const mediaJobIds = Array.isArray(mediaFollowup?.enqueued_job_ids) ? mediaFollowup.enqueued_job_ids : []; - const mediaStatus = + const mediaStatus = normalizeHistoricalAttachedLaneStatus( + terminalRunStatus, terminalRunStatus && mediaFollowup?.source === "catalog_media_mirror" && mediaJobIds.length === 0 && (enqueuedCount === null || enqueuedCount <= 0) ? terminalRunStatus - : mediaFollowup?.status ?? null; + : mediaFollowup?.status ?? mediaFollowup?.state ?? null, + ); cards.push({ key: "media", label: "Media", @@ -2493,6 +3051,28 @@ const getCatalogAggregateRunStatus = (runStatus?: string | null, laneCards: Cata return normalizedRunStatus || statuses[0] || ""; }; +const getCatalogAllLanesComplete = (runStatus?: string | null, laneCards: CatalogLaneCard[] = []): boolean => { + if (normalizeCatalogLaneStatus(runStatus) !== "completed") return false; + return laneCards.every((lane) => normalizeCatalogLaneStatus(lane.status) === "completed"); +}; + +const getCatalogLaneSummaryLabel = (runStatus?: string | null, allLanesComplete = false): string => { + const normalizedRunStatus = normalizeCatalogLaneStatus(runStatus); + if (allLanesComplete) return "All Lanes Complete"; + if (normalizedRunStatus === "cancelled") return "Run Cancelled"; + if (normalizedRunStatus === "failed") return "Run Failed"; + if (ACTIVE_CATALOG_RUN_STATUSES.has(normalizedRunStatus || "")) return "Lanes Running"; + return "Lanes Pending"; +}; + +const getCatalogLaneSummaryTone = (runStatus?: string | null, allLanesComplete = false): string => { + const normalizedRunStatus = normalizeCatalogLaneStatus(runStatus); + if (allLanesComplete) return "border-emerald-200 bg-emerald-50 text-emerald-700"; + if (normalizedRunStatus === "failed") return "border-red-200 bg-red-50 text-red-700"; + if (normalizedRunStatus === "cancelled") return "border-zinc-200 bg-zinc-50 text-zinc-600"; + return "border-amber-200 bg-amber-50 text-amber-700"; +}; + const formatCatalogAggregateRunStatusLabel = (runStatus?: string | null, laneCards: CatalogLaneCard[] = []): string => { const hasFollowupLanes = laneCards.some((lane) => lane.key !== "catalog"); const aggregateStatus = getCatalogAggregateRunStatus(runStatus, laneCards); @@ -2505,6 +3085,7 @@ const getCatalogRunStatusTone = (value?: string | null): string => { if (normalized === "completed") return "border-emerald-200 bg-emerald-50 text-emerald-700"; if (normalized === "failed") return "border-red-200 bg-red-50 text-red-700"; if (normalized === "cancelled") return "border-amber-200 bg-amber-50 text-amber-700"; + if (normalized === "not_started" || normalized === "skipped") return "border-zinc-200 bg-zinc-50 text-zinc-600"; return "border-sky-200 bg-sky-50 text-sky-700"; }; @@ -2864,17 +3445,10 @@ const getCatalogDispatchStatusMessage = (progress?: SocialAccountCatalogRunProgr const getCatalogLaunchGuardMessage = ( progress?: SocialAccountCatalogRunProgressSnapshot | null, - cookieHealth?: SocialProfileCookieHealth | null, ): string | null => { const operationalState = normalizeCatalogRunState(progress?.operational_state); if (operationalState === "blocked_auth") { - if (isInstagramPostsAuthVerified(cookieHealth)) { - return null; - } - const reason = formatDiagnosticToken(progress?.repairable_reason); - return reason - ? `Catalog launch is blocked until the required manual auth is complete. Reason: ${reason}.` - : "Catalog launch is blocked until the required manual auth is complete."; + return null; } const dispatchMessage = getCatalogDispatchStatusMessage(progress); if (dispatchMessage?.tone === "red") { @@ -2898,9 +3472,6 @@ const formatCookieHealthWarningLabel = (cookieHealth?: SocialProfileCookieHealth return "Warning"; }; -const INSTAGRAM_BACKFILL_BLOCKED_AUTH_MESSAGE = - "Instagram backfill blocked before jobs were queued. Local cookies are present, but Modal posts auth was not accepted by Instagram. Complete manual auth first, then sync already validated cookies and rerun Backfill Posts."; - const isInstagramPostsAuthVerified = (cookieHealth?: SocialProfileCookieHealth | null): boolean => { const health = cookieHealth?.posts_auth_health ?? null; const probe = cookieHealth?.posts_auth_probe as JsonRecord | null | undefined; @@ -2963,9 +3534,33 @@ const getInstagramCommentsAuthStatusCopy = ( const probeStatus = String(probe?.status || probe?.result || "").trim().toLowerCase(); const healthStatus = String(health?.status || "").trim().toLowerCase(); const status = probeStatus || healthStatus; - if (health?.ready === true || probe?.ready === true || status === "valid") { + const publicReady = + health?.public_ready === true || + health?.category === "public" || + probe?.public_ready === true || + status === "public"; + const reason = String(health?.reason || probe?.reason || "").trim().toLowerCase(); + const rateLimited = + health?.rate_limited === true || + health?.category === "rate_limited" || + probe?.rate_limited === true || + reason === "http_429" || + reason === "rate_limited" || + reason.includes("429"); + const authenticatedReady = + health?.authenticated_ready === true || + (health?.ready === true && health?.auth_probe_skipped !== true && status !== "public") || + (probe?.authenticated_ready === true || (probe?.ready === true && probe?.auth_probe_skipped !== true && status !== "public")) || + (status === "valid" && health?.auth_probe_skipped !== true && probe?.auth_probe_skipped !== true); + if (authenticatedReady) { return { tone: "text-emerald-700", text: "Modal comments auth verified" }; } + if (rateLimited) { + return { tone: "text-amber-700", text: "Instagram comments auth rate-limited; wait before retrying" }; + } + if (publicReady) { + return { tone: "text-amber-700", text: "Public comments scraping available; authenticated comments not verified" }; + } if (status === "transport_blocked") { return { tone: "text-amber-700", text: "Instagram comments transport blocked" }; } @@ -3148,6 +3743,12 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const [summaryError, setSummaryError] = useState(null); const [summarySaturationActive, setSummarySaturationActive] = useState(false); const [dashboardFreshness, setDashboardFreshness] = useState(null); + const [profileSnapshotPerformance, setProfileSnapshotPerformance] = useState<{ + durationMs: number; + cacheStatus: string; + dashboardStatus: string | null; + dashboardSource: string | null; + } | null>(null); const [accountSwitcherOpen, setAccountSwitcherOpen] = useState(false); const [sharedAccountSources, setSharedAccountSources] = useState([]); const [sharedAccountSourcesLoading, setSharedAccountSourcesLoading] = useState(false); @@ -3170,10 +3771,14 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const [catalogDetail, setCatalogDetail] = useState(null); const [catalogDetailLoading, setCatalogDetailLoading] = useState(false); const [catalogDetailError, setCatalogDetailError] = useState(null); + const catalogDetailRequestKeyRef = useRef(null); const [catalogCardPreview, setCatalogCardPreview] = useState<{ total: number; latestPostedAt: string | null; } | null>(null); + const [instagramCompletionSummary, setInstagramCompletionSummary] = useState(null); + const [instagramCompletionSummaryLoading, setInstagramCompletionSummaryLoading] = useState(false); + const [instagramCompletionSummaryError, setInstagramCompletionSummaryError] = useState(null); const [catalogPage, setCatalogPage] = useState(1); const [catalogFilter, setCatalogFilter] = useState<"all" | "assigned" | "unassigned" | "ambiguous" | "needs_review">("all"); const [instagramBackfillDialogOpen, setInstagramBackfillDialogOpen] = useState(false); @@ -3194,6 +3799,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } () => buildInstagramBackfillMonthDefaults().monthEnd, ); const [catalogActionMessage, setCatalogActionMessage] = useState(null); + const [pendingInstagramApply, setPendingInstagramApply] = useState(null); const [runningCatalogAction, setRunningCatalogAction] = useState< | "backfill" | "clear_stuck_jobs" @@ -3216,9 +3822,16 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const [hashtagsError, setHashtagsError] = useState(null); const [hashtagsLoadedRequestKey, setHashtagsLoadedRequestKey] = useState(null); const [hashtagWindow, setHashtagWindow] = useState<(typeof HASHTAG_WINDOW_OPTIONS)[number]["value"]>("all"); + const [hashtagAssignmentStatus, setHashtagAssignmentStatus] = useState("all"); const [savingHashtag, setSavingHashtag] = useState(null); + const [bulkSavingHashtags, setBulkSavingHashtags] = useState(false); + const [bulkAssignShowId, setBulkAssignShowId] = useState(""); + const [bulkSelectedHashtags, setBulkSelectedHashtags] = useState>(new Set()); const [saveMessage, setSaveMessage] = useState(null); const [draftAssignments, setDraftAssignments] = useState>({}); + const [hashtagConflicts, setHashtagConflicts] = useState([]); + const [hashtagConflictsLoading, setHashtagConflictsLoading] = useState(false); + const [hashtagConflictsError, setHashtagConflictsError] = useState(null); const [hashtagTimeline, setHashtagTimeline] = useState(null); const [hashtagTimelineLoading, setHashtagTimelineLoading] = useState(false); const [hashtagTimelineError, setHashtagTimelineError] = useState(null); @@ -3233,6 +3846,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const [catalogGapAnalysisLoading, setCatalogGapAnalysisLoading] = useState(false); const [catalogGapAnalysisError, setCatalogGapAnalysisError] = useState(null); const [catalogGapAnalysisRequestNonce, setCatalogGapAnalysisRequestNonce] = useState(0); + const catalogGapAnalysisOperationRef = useRef(null); const [collaboratorsTags, setCollaboratorsTags] = useState(null); const [collaboratorsLoading, setCollaboratorsLoading] = useState(false); @@ -3253,6 +3867,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const [cancellingActiveCommentsRun, setCancellingActiveCommentsRun] = useState(false); const [resumingActiveCommentsRun, setResumingActiveCommentsRun] = useState(false); const [restartingActiveCommentsRun, setRestartingActiveCommentsRun] = useState(false); + const [repairingActiveCommentsAuth, setRepairingActiveCommentsAuth] = useState(false); const [cancellingCommentsJobId, setCancellingCommentsJobId] = useState(null); const [dismissingCatalogRunId, setDismissingCatalogRunId] = useState(null); const [pendingDismissedCatalogRunIds, setPendingDismissedCatalogRunIds] = useState>(new Set()); @@ -3288,6 +3903,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const [cookieRefreshMessage, setCookieRefreshMessage] = useState(null); const [cookieRefreshConfirmationPending, setCookieRefreshConfirmationPending] = useState(false); const [catalogRepairAuthConfirmationRunId, setCatalogRepairAuthConfirmationRunId] = useState(null); + const [commentsRepairAuthConfirmationRunId, setCommentsRepairAuthConfirmationRunId] = useState(null); const [secondaryReadsGateOpen, setSecondaryReadsGateOpen] = useState(false); const [secondaryReadBudgetSlot, setSecondaryReadBudgetSlot] = useState(0); const [secondaryReadsPressurePause, setSecondaryReadsPressurePause] = useState<{ @@ -3410,7 +4026,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setSharedAccountSourcesLoading(false); } }, [fetchAdminWithAuth, sharedAccountSources.length, sharedAccountSourcesLoading, user]); - const hashtagsRequestKey = `hashtags:${platform}:${handle}:${hashtagWindow}`; + const hashtagsRequestKey = `hashtags:${platform}:${handle}:${hashtagWindow}:${hashtagAssignmentStatus}`; const hashtagTimelineRequestKey = `hashtags-timeline:${platform}:${handle}:${hashtagWindow}`; const collaboratorsRequestKey = `collaborators-tags:${platform}:${handle}`; const selectedTabPrimaryReadActive = @@ -3455,10 +4071,11 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } }, []); const visibleCatalogRecentRuns = useMemo(() => { const runs = summary?.catalog_recent_runs ?? []; - if (pendingDismissedCatalogRunIds.size === 0) { - return runs; - } - return runs.filter((run) => !pendingDismissedCatalogRunIds.has(String(run.run_id || "").trim())); + const filteredRuns = + pendingDismissedCatalogRunIds.size === 0 + ? runs + : runs.filter((run) => !pendingDismissedCatalogRunIds.has(String(run.run_id || "").trim())); + return dedupeCatalogRecentRuns(filteredRuns); }, [pendingDismissedCatalogRunIds, summary?.catalog_recent_runs]); const activeCatalogRun = useMemo(() => { const runs = visibleCatalogRecentRuns; @@ -3599,6 +4216,12 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setHashtagsError(null); setHashtagsLoadedRequestKey(null); setDraftAssignments({}); + setBulkAssignShowId(""); + setBulkSavingHashtags(false); + setBulkSelectedHashtags(new Set()); + setHashtagConflicts([]); + setHashtagConflictsLoading(false); + setHashtagConflictsError(null); hashtagResponseCacheRef.current.clear(); setCatalogProgressRunId(null); setCommentsProgressRunId(null); @@ -3624,6 +4247,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setCatalogGapAnalysis(null); setCatalogGapAnalysisStatus("idle"); setCatalogGapAnalysisOperationId(null); + catalogGapAnalysisOperationRef.current = null; setCatalogGapAnalysisStale(false); setCatalogGapAnalysisLoading(false); setCatalogGapAnalysisError(null); @@ -3824,14 +4448,21 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } }, { preferredUser: user }, ); + const durationMs = nowMs() - requestStartedAt; recordAdminLoadSample({ surface: "admin-social-profile-snapshot", path: `/social/${platform}/${handle}`, source: "api", - duration_ms: nowMs() - requestStartedAt, + duration_ms: durationMs, cache_status: response.headers.get("x-trr-cache"), server_timing: response.headers.get("server-timing"), }); + setProfileSnapshotPerformance({ + durationMs, + cacheStatus: response.headers.get("x-trr-cache") ?? "miss", + dashboardStatus: response.headers.get("x-trr-dashboard-freshness"), + dashboardSource: response.headers.get("x-trr-dashboard-source"), + }); const envelope = (await response.json().catch(() => ({}))) as | SocialAccountProfileSnapshot | ({ @@ -4024,6 +4655,9 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } if (hashtagWindow !== "all") { query.set("window", hashtagWindow); } + if (hashtagAssignmentStatus !== "all") { + query.set("assignment_status", hashtagAssignmentStatus); + } const data = await withSocialProfileRequestDedup(hashtagsRequestKey, async () => { const response = await fetchAdminWithAuth( `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/hashtags${ @@ -4047,7 +4681,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setHashtags(nextItems); setHashtagsLoadedRequestKey(hashtagsRequestKey); setDraftAssignments(nextDraftAssignments); - }, [fetchAdminWithAuth, handle, hashtagWindow, hashtagsRequestKey, platform, user]); + }, [fetchAdminWithAuth, handle, hashtagAssignmentStatus, hashtagWindow, hashtagsRequestKey, platform, user]); const refreshHashtagTimeline = useCallback(async () => { if (!user || platform !== "instagram") return; @@ -4161,6 +4795,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } }, [applyProfileSnapshotSummary, checking, hasAccess, user]); useEffect(() => { + const liveProfileTotalRetryRequested = liveProfileTotalRequestNonce > 0; if ( checking || !user || @@ -4169,7 +4804,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } summaryLoading || !hasCurrentSummary || !secondaryReadsGateOpen || - Boolean(secondaryReadsPressurePause) || + (!liveProfileTotalRetryRequested && Boolean(secondaryReadsPressurePause)) || secondaryReadBudgetSlot < 2 ) { return; @@ -4292,14 +4927,15 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } undefined, { preferredUser: user }, ); - const data = (await response.json().catch(() => ({}))) as CatalogPostsResponse & { error?: string }; + const data = (await response.json().catch(() => ({}))) as Partial & { error?: string }; if (!response.ok) { throw new Error(data.error || "Failed to load catalog preview"); } if (cancelled) return; + const normalizedData = normalizeCatalogPostsResponse(data, 1, 1); setCatalogCardPreview({ - total: Number(data.pagination?.total ?? 0), - latestPostedAt: data.items?.[0]?.posted_at ?? null, + total: normalizedData.pagination.total, + latestPostedAt: normalizedData.items[0]?.posted_at ?? null, }); } catch (error) { pauseSecondaryReadsForPressure(error, "Failed to load catalog preview"); @@ -4413,15 +5049,15 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } undefined, { preferredUser: user }, ); - const data = (await response.json().catch(() => ({}))) as CatalogPostsResponse & { error?: string }; + const data = (await response.json().catch(() => ({}))) as Partial & ProxyErrorPayload; if (!response.ok) { - throw new Error(data.error || "Failed to load social account catalog posts"); + throw buildSocialAccountRequestError(data, "Failed to load social account catalog posts"); } if (cancelled) return; - setCatalogPosts(data); + setCatalogPosts(normalizeCatalogPostsResponse(data, catalogPage, 25)); } catch (error) { if (cancelled) return; - setCatalogPostsError(error instanceof Error ? error.message : "Failed to load social account catalog posts"); + setCatalogPostsError(formatCatalogGalleryErrorMessage(error)); } finally { if (!cancelled) setCatalogPostsLoading(false); } @@ -4449,6 +5085,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const hasLoadedExactHashtagWindow = hashtagsLoadedRequestKey === hashtagsRequestKey; const useSummaryTopHashtagsPreview = shouldUseSummaryTopHashtagsPreview({ activeTab: selectedTab, + hashtagAssignmentStatus, hashtagWindow, summaryTopHashtags: summary?.top_hashtags ?? [], hasLoadedExactWindow: hasLoadedExactHashtagWindow, @@ -4523,6 +5160,59 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } user, ]); + useEffect(() => { + if ( + checking || + !user || + !hasAccess || + selectedTab !== "hashtags" || + !hasCurrentSummary || + summaryUninitialized + ) { + return; + } + let cancelled = false; + + const loadHashtagConflicts = async () => { + setHashtagConflictsLoading(true); + setHashtagConflictsError(null); + try { + const response = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/hashtags/conflicts?limit=10`, + undefined, + { preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as HashtagConflictsResponse & { error?: string }; + if (!response.ok) { + throw new Error(data.error || "Failed to load hashtag backfill conflicts"); + } + if (cancelled) return; + setHashtagConflicts(data.items ?? []); + } catch (error) { + if (cancelled) return; + setHashtagConflicts([]); + setHashtagConflictsError(error instanceof Error ? error.message : "Failed to load hashtag backfill conflicts"); + } finally { + if (!cancelled) setHashtagConflictsLoading(false); + } + }; + + void loadHashtagConflicts(); + return () => { + cancelled = true; + }; + }, [ + checking, + fetchAdminWithAuth, + handle, + hasAccess, + hasCurrentSummary, + platform, + selectedTab, + summaryUninitialized, + user, + ]); + useEffect(() => { if ( checking || @@ -4708,6 +5398,63 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } supportsCatalog, ]); + const instagramCompletionYear = useMemo(() => { + const startYear = readBackfillMonthYear(instagramBackfillMonthStart); + const endYear = readBackfillMonthYear(instagramBackfillMonthEnd); + if (startYear !== null && endYear !== null && startYear === endYear) { + return startYear; + } + return endYear ?? startYear ?? getCurrentUtcYear(); + }, [instagramBackfillMonthEnd, instagramBackfillMonthStart]); + + useEffect(() => { + if (checking || !user || !hasAccess || platform !== "instagram" || selectedTab !== "catalog") { + setInstagramCompletionSummary(null); + setInstagramCompletionSummaryLoading(false); + setInstagramCompletionSummaryError(null); + return; + } + let cancelled = false; + setInstagramCompletionSummaryLoading(true); + setInstagramCompletionSummaryError(null); + const loadCompletionSummary = async () => { + try { + const response = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/completion-summary?year=${instagramCompletionYear}`, + undefined, + { preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as Partial & { error?: string }; + if (!response.ok) { + throw new Error(data.error || `Failed to load ${instagramCompletionYear} completion summary`); + } + if (!cancelled) setInstagramCompletionSummary(data as InstagramCompletionSummary); + } catch (error) { + pauseSecondaryReadsForPressure(error, `Failed to load ${instagramCompletionYear} completion summary`); + if (!cancelled) { + setInstagramCompletionSummary(null); + setInstagramCompletionSummaryError("Unavailable"); + } + } finally { + if (!cancelled) setInstagramCompletionSummaryLoading(false); + } + }; + void loadCompletionSummary(); + return () => { + cancelled = true; + }; + }, [ + checking, + fetchAdminWithAuth, + handle, + hasAccess, + instagramCompletionYear, + pauseSecondaryReadsForPressure, + platform, + selectedTab, + user, + ]); + const displayTotalPosts = useMemo(() => { if (Number(summary?.live_total_posts ?? 0) > 0) { return Number(summary?.live_total_posts ?? 0); @@ -4729,16 +5476,22 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } return "Cataloged / Profile total"; }, [summary?.live_total_posts, supportsCatalog]); + const displayedInstagramCompletionYear = instagramCompletionSummary?.year ?? instagramCompletionYear; + const applyCatalogGapAnalysisStatus = useCallback( - (payload: CatalogGapAnalysisStatusPayload) => { + (payload: CatalogGapAnalysisStatusPayload, expectedOperationId?: string | null): boolean => { const normalizedStatus = payload.status ?? "idle"; const operationId = typeof payload.operation_id === "string" && payload.operation_id.trim() ? payload.operation_id.trim() : null; + if (expectedOperationId && operationId && operationId !== expectedOperationId) { + return false; + } setCatalogGapAnalysis(payload.result ?? null); setCatalogGapAnalysisStatus(normalizedStatus); setCatalogGapAnalysisOperationId(operationId); + catalogGapAnalysisOperationRef.current = operationId; setCatalogGapAnalysisStale(Boolean(payload.stale)); setCatalogGapAnalysisLoading(normalizedStatus === "queued" || normalizedStatus === "running"); if (normalizedStatus === "failed") { @@ -4748,6 +5501,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } else { setCatalogGapAnalysisError(null); } + return true; }, [], ); @@ -4931,6 +5685,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setCatalogGapAnalysis(null); setCatalogGapAnalysisStatus("idle"); setCatalogGapAnalysisOperationId(null); + catalogGapAnalysisOperationRef.current = null; setCatalogGapAnalysisStale(false); setCatalogGapAnalysisError(null); setCatalogGapAnalysisLoading(false); @@ -4944,6 +5699,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setCatalogGapAnalysis(null); setCatalogGapAnalysisStatus("idle"); setCatalogGapAnalysisOperationId(null); + catalogGapAnalysisOperationRef.current = null; setCatalogGapAnalysisStale(false); setCatalogGapAnalysisError(null); setCatalogGapAnalysisLoading(false); @@ -5050,7 +5806,11 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } if (cancelled) return; saturationAttempt = 0; - applyCatalogGapAnalysisStatus(data); + const applied = applyCatalogGapAnalysisStatus(data, catalogGapAnalysisOperationId); + if (!applied) { + scheduleNextPoll(CATALOG_GAP_ANALYSIS_POLL_INTERVAL_MS); + return; + } if (data.status === "queued" || data.status === "running") { scheduleNextPoll(CATALOG_GAP_ANALYSIS_POLL_INTERVAL_MS); } @@ -5120,6 +5880,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } let cancelled = false; + const controller = new AbortController(); const timeout = window.setTimeout(() => { void (async () => { setPostSearchLoading(true); @@ -5132,20 +5893,20 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } }); const response = await fetchAdminWithAuth( `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/posts?${query.toString()}`, - undefined, + { signal: controller.signal }, { preferredUser: user }, ); const data = (await response.json().catch(() => ({}))) as PostsResponse & { error?: string }; if (!response.ok) { throw new Error(data.error || "Failed to search social account captions"); } - if (cancelled) return; + if (cancelled || controller.signal.aborted) return; setPostSearchResults(data); } catch (error) { - if (cancelled) return; + if (cancelled || controller.signal.aborted) return; setPostSearchError(error instanceof Error ? error.message : "Failed to search social account captions"); } finally { - if (!cancelled) { + if (!cancelled && !controller.signal.aborted) { setPostSearchLoading(false); } } @@ -5155,6 +5916,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } return () => { cancelled = true; window.clearTimeout(timeout); + controller.abort(); }; }, [checking, fetchAdminWithAuth, handle, hasAccess, platform, postSearchExpanded, summaryUninitialized, trimmedPostSearchQuery, user]); @@ -5231,6 +5993,13 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } ]); const shouldRenderCatalogRunProgressCard = shouldShowCatalogRunProgressCard && supportsCatalog && shouldDisplayCatalogRunProgressCard; + const shouldDeemphasizeCatalogSummaryState = shouldShowCatalogRunProgressCard && supportsCatalog; + const catalogRecentHistoryRuns = useMemo(() => { + const displayedRunId = + shouldRenderCatalogRunProgressCard ? String(displayedCatalogRunId || "").trim().toLowerCase() : ""; + if (!displayedRunId) return visibleCatalogRecentRuns; + return visibleCatalogRecentRuns.filter((run) => String(run.run_id || "").trim().toLowerCase() !== displayedRunId); + }, [displayedCatalogRunId, shouldRenderCatalogRunProgressCard, visibleCatalogRecentRuns]); useEffect(() => { if (checking || !user || !hasAccess || !supportsCatalog) return; @@ -5533,6 +6302,14 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } Boolean(activeCommentsRunId) && !rawActiveCommentsRunProgressIsStale && (activeCommentsRunIsActive || activeCommentsRunIsPublicBlockedPaused); + const activeCommentsRunCanRepairAuth = + platform === "instagram" && + Boolean(activeCommentsRunId) && + !rawActiveCommentsRunProgressIsStale && + (activeCommentsRunProgressData?.manual_auth_required === true || + String(activeCommentsRunProgressData?.operational_state || "").trim().toLowerCase() === "blocked_auth" || + String(activeCommentsRunProgressData?.recommended_next_action || "").trim().toLowerCase() === + "repair_auth_then_retry"); const activeCommentsRunDateWindow = useMemo( () => readCommentsRunDateWindow(activeCommentsRunProgressData), [activeCommentsRunProgressData], @@ -5611,8 +6388,19 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const autoRebalance = progress?.auto_rebalance; const autoRebalancedJobCount = getNumberFromRecord(autoRebalance, ["created_job_count"]); const accessProofLabel = formatInstagramAccessProofLabel(progress); + const terminalNotice = (() => { + const status = String(progress?.run_status || activeCommentsRunEffectiveStatus || "").trim().toLowerCase(); + if (!TERMINAL_CATALOG_RUN_STATUSES.has(status)) return null; + const baseMessage = progress?.error_message || `Comments run ${formatRunStatusLabel(status)}.`; + if (status !== "cancelled") return baseMessage; + const remainingTargets = readFiniteNumber(progress?.cancellation_summary?.remaining_target_source_ids_count); + return remainingTargets !== null && remainingTargets > 0 + ? `${baseMessage} ${formatInteger(remainingTargets)} targets remain; use Incomplete Fill to continue only unfinished posts.` + : baseMessage; + })(); return { status: formatRunStatusLabel(activeCommentsRunEffectiveStatus || "running"), + terminalNotice, jobSummary: formatCommentsShardJobSummary(progress), completedPosts, totalPosts, @@ -5719,8 +6507,8 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } }, [activeCommentsSummarySnapshot.data, applyProfileSnapshotSummary]); const catalogLaunchGuardMessage = useMemo(() => { - return getCatalogLaunchGuardMessage(catalogRunProgress, cookieHealth); - }, [catalogRunProgress, cookieHealth]); + return getCatalogLaunchGuardMessage(catalogRunProgress); + }, [catalogRunProgress]); const instagramCommentsModalAuthActive = useMemo(() => { if (platform !== "instagram") return false; const activeCommentsStatus = String(activeCommentsRunProgressData?.run_status || "").trim().toLowerCase(); @@ -5780,26 +6568,6 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } activeCommentsRunBlocksActions || Boolean(catalogLaunchGuardMessage); - useEffect(() => { - if (platform !== "instagram") return; - if (String(catalogRunProgress?.operational_state || "").trim().toLowerCase() !== "blocked_auth") return; - if (isInstagramPostsAuthVerified(cookieHealth)) { - setCatalogActionMessage((current) => (current === INSTAGRAM_BACKFILL_BLOCKED_AUTH_MESSAGE ? null : current)); - return; - } - const repairStatus = String(catalogRunProgress?.repair_status || catalogRunProgress?.auth_repair_status || "") - .trim() - .toLowerCase(); - if (repairStatus === "running" || repairStatus === "succeeded") return; - setCatalogActionMessage(INSTAGRAM_BACKFILL_BLOCKED_AUTH_MESSAGE); - }, [ - catalogRunProgress?.auth_repair_status, - catalogRunProgress?.operational_state, - catalogRunProgress?.repair_status, - cookieHealth, - platform, - ]); - useEffect(() => { if (platform !== "instagram") return; const currentRunId = String(catalogRunProgress?.run_id || "").trim(); @@ -5887,8 +6655,85 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const activeCatalogSourceScope = useMemo(() => { return resolveActiveCatalogSourceScope(summary, catalogRunProgress); }, [catalogRunProgress, summary]); - const displayedCatalogRunStatusLabel = useMemo(() => { - return getCatalogRunDisplayStatusLabel(displayedCatalogRunStatus, catalogRunProgress); + + useEffect(() => { + if (platform !== "instagram") { + setPendingInstagramApply(null); + return; + } + const currentRunId = String(catalogRunProgress?.run_id || "").trim(); + const launchState = String(catalogRunProgress?.launch_state || "").trim().toLowerCase(); + const applyRunId = + String(catalogRunProgress?.apply_run_id || "").trim() || + (launchState === "pending_apply_confirmation" ? currentRunId : ""); + const applyRequired = Boolean( + catalogRunProgress?.requires_apply_confirmation || + catalogRunProgress?.apply_required || + launchState === "pending_apply_confirmation", + ); + if (!applyRequired || !applyRunId) { + setPendingInstagramApply((current) => (current?.applyRunId === currentRunId ? null : current)); + return; + } + const selectedTasks = + catalogRunProgress?.effective_selected_tasks?.length ? catalogRunProgress.effective_selected_tasks + : catalogRunProgress?.selected_tasks?.length ? catalogRunProgress.selected_tasks + : [...INSTAGRAM_BACKFILL_DEFAULT_SELECTED_TASKS]; + const requestBody: CatalogBackfillRequest = { + source_scope: (String(catalogRunProgress?.source_scope || activeCatalogSourceScope || "network").trim() || + "network") as NonNullable, + backfill_scope: + catalogRunProgress?.date_start || catalogRunProgress?.date_end ? "bounded_window" : "full_history", + date_start: catalogRunProgress?.date_start ?? null, + date_end: catalogRunProgress?.date_end ?? null, + selected_tasks: selectedTasks, + ...(catalogRunProgress?.detail_worker_count ? { detail_worker_count: catalogRunProgress.detail_worker_count } : {}), + ...(catalogRunProgress?.comments_worker_count + ? { comments_worker_count: catalogRunProgress.comments_worker_count } + : {}), + ...(catalogRunProgress?.comments_enable_media_followups != null + ? { comments_enable_media_followups: catalogRunProgress.comments_enable_media_followups } + : {}), + ...(catalogRunProgress?.enable_cap4_canary ? { enable_cap4_canary: true } : {}), + }; + const requiredConfirmation = + String(catalogRunProgress?.required_confirmation || "").trim() || buildInstagramApplyConfirmation(applyRunId); + setPendingInstagramApply((current) => { + if ( + current?.applyRunId === applyRunId && + current.requiredConfirmation === requiredConfirmation && + JSON.stringify(current.requestBody) === JSON.stringify(requestBody) + ) { + return current; + } + return { + requestBody, + applyRunId, + requiredConfirmation, + }; + }); + }, [ + activeCatalogSourceScope, + catalogRunProgress?.apply_required, + catalogRunProgress?.apply_run_id, + catalogRunProgress?.comments_enable_media_followups, + catalogRunProgress?.comments_worker_count, + catalogRunProgress?.date_end, + catalogRunProgress?.date_start, + catalogRunProgress?.detail_worker_count, + catalogRunProgress?.effective_selected_tasks, + catalogRunProgress?.enable_cap4_canary, + catalogRunProgress?.launch_state, + catalogRunProgress?.required_confirmation, + catalogRunProgress?.requires_apply_confirmation, + catalogRunProgress?.run_id, + catalogRunProgress?.selected_tasks, + catalogRunProgress?.source_scope, + platform, + ]); + + const displayedCatalogRunStatusLabel = useMemo(() => { + return getCatalogRunDisplayStatusLabel(displayedCatalogRunStatus, catalogRunProgress); }, [catalogRunProgress, displayedCatalogRunStatus]); const catalogBlockedAuthPresentation = useMemo(() => { if (String(catalogRunProgress?.operational_state || "").trim().toLowerCase() !== "blocked_auth") { @@ -6552,6 +7397,10 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } issueReasons: Array.from(issueReasons).slice(0, 3), }; }, [activeCommentsProgressRankedRows, activeCommentsRunProgressData?.worker_counters]); + const commentsShardAttentionRecommendation = useMemo( + () => getCommentsShardAttentionRecommendation(activeCommentsRunProgressData, commentsShardHealthSummary.issueReasons), + [activeCommentsRunProgressData, commentsShardHealthSummary.issueReasons], + ); const catalogLaneCards = useMemo( () => @@ -6561,13 +7410,20 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } selectedTasks: catalogRunProgress?.selected_tasks, effectiveSelectedTasks: catalogRunProgress?.effective_selected_tasks, commentsRunId: catalogRunProgress?.comments_run_id, - attachedFollowups: catalogRunProgress?.attached_followups, + attachedFollowups: catalogRunProgress?.attached_followups ?? displayedCatalogRunSummary?.attached_followups, }), [ catalogRunProgress, + displayedCatalogRunSummary?.attached_followups, displayedCatalogRunStatus, ], ); + const catalogAllLanesComplete = getCatalogAllLanesComplete( + catalogRunProgress?.run_status ?? displayedCatalogRunStatus, + catalogLaneCards, + ); + const catalogLaneSummaryLabel = getCatalogLaneSummaryLabel(displayedCatalogRunStatus, catalogAllLanesComplete); + const catalogLaneSummaryTone = getCatalogLaneSummaryTone(displayedCatalogRunStatus, catalogAllLanesComplete); const catalogTerminalCoverageMessage = useMemo(() => { if (catalogProgressMode !== "coverage") return null; @@ -6722,6 +7578,54 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const mediaCoverageUnavailable = !summaryInitialStatePending && summary?.media_coverage == null; + const instagramCompletionLanes = useMemo(() => { + const lanes = instagramCompletionSummary?.lanes; + if (!lanes) return []; + return [ + { key: "comments", label: "Comments", summary: lanes.comments }, + { key: "details", label: "Details", summary: lanes.details }, + { key: "media", label: "Media", summary: lanes.media }, + ]; + }, [instagramCompletionSummary?.lanes]); + + const streamingCommentsGapRows = useMemo( + () => + buildInstagramCommentsGapDisplayRows( + activeCommentsRunProgressData?.target_progress_rows, + activeCommentsRunProgressData?.target_progress, + activeCommentsRunProgressData?.retry_progress?.target_progress_rows, + activeCommentsRunProgressData?.largest_remaining_gaps, + activeCommentsRunProgressData?.retry_progress?.largest_remaining_gaps, + activeCommentsRunProgressData?.largest_gaps, + activeCommentsRunProgressData?.incomplete_targets, + ), + [activeCommentsRunProgressData], + ); + + const summaryCommentsGapRows = useMemo( + () => buildInstagramCommentsGapDisplayRows(instagramCompletionSummary?.comment_gap_posts), + [instagramCompletionSummary?.comment_gap_posts], + ); + + const dashboardCommentsGapRows = + streamingCommentsGapRows.length > 0 ? streamingCommentsGapRows : summaryCommentsGapRows; + const dashboardCommentsGapSourceLabel = + streamingCommentsGapRows.length > 0 + ? activeCommentsRunId + ? `Live stream · run ${shortRunId(activeCommentsRunId)}` + : "Live stream" + : summaryCommentsGapRows.length > 0 + ? `${displayedInstagramCompletionYear} summary` + : null; + const commentsStreamingBanner = useMemo( + () => + buildInstagramCommentsStreamingBanner( + catalogRunProgress?.comments_streaming ?? activeCommentsRunProgressData?.catalog_streaming, + activeCommentsRunId, + ), + [activeCommentsRunId, activeCommentsRunProgressData?.catalog_streaming, catalogRunProgress?.comments_streaming], + ); + const catalogProgressDiagnosticRows = useMemo( () => buildCatalogProgressDiagnosticRows(catalogRunProgress, summary), [catalogRunProgress, summary], @@ -6830,7 +7734,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } .join(" · "), recommendation: commentsShardHealthSummary.failed > 0 || commentsShardHealthSummary.retrying > 0 - ? "Leave the run active unless a shard stops moving; cancel only the stuck shard." + ? commentsShardAttentionRecommendation : "Let comments continue separately from post/details work.", progressValue: activeCommentsProgressSummary.postsPercent ?? null, }); @@ -6896,6 +7800,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } catalogAlertCodes, catalogStuckQueueRecoveryRecommended, commentsSavedCount, + commentsShardAttentionRecommendation, commentsShardHealthSummary.failed, commentsShardHealthSummary.queued, commentsShardHealthSummary.retrying, @@ -7032,7 +7937,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } ] .filter(Boolean) .join(". "), - recommendation: "Cancel only a shard that is stuck; failed or retrying shards do not mean the whole comments run is dead.", + recommendation: commentsShardAttentionRecommendation, tone: commentsShardHealthSummary.failed > 0 ? "red" : "amber", }); } @@ -7067,6 +7972,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } catalogRunProgressError, catalogStopMessage, commentsShardHealthSummary.failed, + commentsShardAttentionRecommendation, commentsShardHealthSummary.issueReasons, commentsShardHealthSummary.retrying, cookieHealth?.auth_surface_blocked, @@ -7320,6 +8226,32 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } })); }, [summary?.per_show_counts]); + const visibleUnassignedHashtags = useMemo(() => { + return hashtags.filter((item) => (draftAssignments[item.hashtag] ?? item.assignments ?? []).length === 0); + }, [draftAssignments, hashtags]); + + const selectedVisibleUnassignedHashtags = useMemo(() => { + const visibleKeys = new Set(visibleUnassignedHashtags.map((item) => item.hashtag)); + return Array.from(bulkSelectedHashtags).filter((hashtag) => visibleKeys.has(hashtag)); + }, [bulkSelectedHashtags, visibleUnassignedHashtags]); + + useEffect(() => { + if (!bulkAssignShowId && showOptions[0]?.show_id) { + setBulkAssignShowId(showOptions[0].show_id); + } + if (bulkAssignShowId && showOptions.length > 0 && !showOptions.some((show) => show.show_id === bulkAssignShowId)) { + setBulkAssignShowId(showOptions[0].show_id); + } + }, [bulkAssignShowId, showOptions]); + + useEffect(() => { + const visibleKeys = new Set(visibleUnassignedHashtags.map((item) => item.hashtag)); + setBulkSelectedHashtags((current) => { + const next = new Set(Array.from(current).filter((hashtag) => visibleKeys.has(hashtag))); + return next.size === current.size ? current : next; + }); + }, [visibleUnassignedHashtags]); + const reviewShowOptionsByItem = useMemo>(() => { const optionsByItem: Record = {}; for (const item of reviewQueue) { @@ -7392,16 +8324,41 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const addHashtagAssignmentRow = (hashtag: string) => { const fallbackShow = showOptions[0]; if (!fallbackShow) return; - const nextAssignments = [...(draftAssignments[hashtag] ?? []), { show_id: fallbackShow.show_id }]; + const nextAssignments = [ + ...(draftAssignments[hashtag] ?? []), + { show_id: fallbackShow.show_id, assignment_scope: "global" as const, platform: null }, + ]; updateHashtagAssignments(hashtag, nextAssignments); }; + const toggleBulkSelectedHashtag = (hashtag: string, checked: boolean) => { + setBulkSelectedHashtags((current) => { + const next = new Set(current); + if (checked) { + next.add(hashtag); + } else { + next.delete(hashtag); + } + return next; + }); + }; + + const selectAllVisibleUnassignedHashtags = () => { + setBulkSelectedHashtags(new Set(visibleUnassignedHashtags.map((item) => item.hashtag))); + }; + + const clearBulkSelectedHashtags = () => { + setBulkSelectedHashtags(new Set()); + }; + const saveHashtagAssignments = async (hashtag: string) => { if (!user) return; - const assignments = (draftAssignments[hashtag] ?? []) + const assignments: SocialAccountProfileHashtagAssignment[] = (draftAssignments[hashtag] ?? []) .filter((assignment) => assignment.show_id) .map((assignment) => ({ show_id: assignment.show_id, + assignment_scope: "global" as const, + platform: null, })); setSavingHashtag(hashtag); @@ -7423,9 +8380,12 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } setHashtags(data.items ?? hashtags); setDraftAssignments((current) => ({ ...current, - [hashtag]: (data.items ?? []).find((item) => item.hashtag === hashtag)?.assignments ?? assignments, + [hashtag]: + (data.items ?? []).find((item) => item.hashtag === hashtag)?.assignments?.map((assignment) => + normalizeHashtagAssignmentDraft(assignment), + ) ?? assignments, })); - setSaveMessage(`Saved assignments for #${hashtag}`); + setSaveMessage(`Saved shared assignments for #${hashtag}`); await refreshProfileSnapshotNow(); } catch (error) { setSaveMessage(error instanceof Error ? error.message : "Failed to save hashtag assignments"); @@ -7434,6 +8394,47 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } }; + const saveBulkHashtagAssignments = async () => { + if (!user || !bulkAssignShowId || selectedVisibleUnassignedHashtags.length === 0) return; + const selectedShow = showOptions.find((show) => show.show_id === bulkAssignShowId); + const assignments: SocialAccountProfileHashtagAssignment[] = [ + { show_id: bulkAssignShowId, assignment_scope: "global" as const, platform: null }, + ]; + setBulkSavingHashtags(true); + setSaveMessage(null); + try { + const response = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/hashtags`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + hashtags: selectedVisibleUnassignedHashtags.map((hashtag) => ({ hashtag, assignments })), + }), + }, + { preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as HashtagsResponse & { error?: string }; + if (!response.ok) { + throw new Error(data.error || "Failed to save bulk hashtag assignments"); + } + const nextItems = cloneHashtagItems(data.items ?? hashtags); + setHashtags(nextItems); + setDraftAssignments(buildHashtagDraftAssignments(nextItems)); + setBulkSelectedHashtags(new Set()); + setSaveMessage( + `Saved ${formatInteger(selectedVisibleUnassignedHashtags.length)} shared assignments${ + selectedShow?.show_name ? ` to ${selectedShow.show_name}` : "" + }`, + ); + await refreshProfileSnapshotNow(); + } catch (error) { + setSaveMessage(error instanceof Error ? error.message : "Failed to save bulk hashtag assignments"); + } finally { + setBulkSavingHashtags(false); + } + }; + // --- Cookie preflight: fetch health on load for cookie-dependent platforms --- const fetchCookieHealth = useCallback( async ( @@ -7715,14 +8716,25 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } requestBody: CatalogBackfillRequest | CatalogSyncRecentRequest | CatalogSyncNewerRequest, ) => { setRunningCatalogAction(action); + const liveApplyRequest = + action === "backfill" && + "apply_run_id" in requestBody && + Boolean(String(requestBody.apply_run_id || "").trim()); + if (liveApplyRequest) { + setCatalogActionMessage("Confirming Live APPLY and finalizing Instagram backfill lanes..."); + } else { + setPendingInstagramApply(null); + } const requestSelectedTasks = "selected_tasks" in requestBody ? requestBody.selected_tasks : undefined; const instagramCommentsSelected = action === "backfill" && platform === "instagram" && (requestSelectedTasks ?? INSTAGRAM_BACKFILL_DEFAULT_SELECTED_TASKS).includes("comments"); - setCatalogActionMessage( - instagramCommentsSelected ? "Checking Instagram auth before preparing backfill..." : null, - ); + if (!liveApplyRequest) { + setCatalogActionMessage( + instagramCommentsSelected ? "Checking Instagram auth before preparing backfill..." : null, + ); + } setCatalogFreshness(null); setCatalogFreshnessError(null); setSecondaryReadsPressurePause(null); @@ -7812,6 +8824,14 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } if (action === "backfill" && platform === "instagram") { const launchTaskResolutionPending = data.launch_state === "pending" || Boolean(data.launch_task_resolution_pending); + const liveApplyRunId = String(data.apply_run_id || data.run_id || "").trim(); + const requiredLiveApplyConfirmation = + String(data.required_confirmation || "").trim() || + (liveApplyRunId ? buildInstagramApplyConfirmation(liveApplyRunId) : ""); + const requiresLiveApplyConfirmation = + Boolean(data.requires_apply_confirmation) && + Boolean(liveApplyRunId) && + Boolean(requiredLiveApplyConfirmation); const attachedFollowups = data.attached_followups ?? null; const attachedComments = attachedFollowups?.comments ?? null; const attachedMedia = attachedFollowups?.media ?? null; @@ -7838,11 +8858,25 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } data.auth_repair_attempted && data.auth_repair_status === "succeeded" ? "Manual Instagram auth validated. " : ""; - if (launchTaskResolutionPending) { + if (requiresLiveApplyConfirmation && "backfill_scope" in requestBody) { + setPendingInstagramApply({ + requestBody: { ...requestBody }, + applyRunId: liveApplyRunId, + requiredConfirmation: requiredLiveApplyConfirmation, + }); + const message = + launchParts.length > 0 + ? `Instagram 2025 backfill reserved. Confirm Live APPLY to enqueue catalog jobs at the configured comments cap. ${launchParts.join(" · ")}.` + : "Instagram 2025 backfill reserved. Confirm Live APPLY to enqueue catalog jobs at the configured comments cap."; + setCatalogActionMessage(`${authRepairPrefix}${message}`); + } else if (launchTaskResolutionPending) { + const currentPhase = liveApplyRequest + ? "Current phase: finalizing task lanes in the background. " + : "Current phase: reserving the run and finalizing task lanes in the background. "; const phaseMessage = `Requested tasks: ${selectedTaskLabels || "Post Details"}. ` + `Source scope: ${formatDiagnosticToken(requestedSourceScope)}. ` + - "Current phase: reserving the run and finalizing task lanes in the background. " + + currentPhase + "Next: verify Instagram posts auth, check target readiness, attach selected follow-up lanes, then workers claim queued jobs."; const message = launchParts.length > 0 @@ -7943,7 +8977,18 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } return; } } - if (platformRequiresCookies && effectiveCookieHealth?.healthy === false) { + const localCookieHealthBlocksCatalogLaunch = + platformRequiresCookies && + platform !== "tiktok" && + !(platform === "instagram" && action === "backfill") && + effectiveCookieHealth?.healthy === false; + if (localCookieHealthBlocksCatalogLaunch) { + // effectiveCookieHealth is guaranteed non-null here (the platformRequiresCookies + // branch above returns early when it cannot be resolved), but narrow explicitly + // so the type-checker can follow it through the rest of this block. + if (!effectiveCookieHealth) { + return; + } const canAutoRepairBeforeLaunch = effectiveCookieHealth.refresh_available && effectiveCookieHealth.refresh_action === "cookie_refresh"; @@ -8026,6 +9071,69 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } user, ]); + const instagramCap4CanaryState = useMemo(() => { + if (platform !== "instagram") { + return { allowed: false, reason: null as string | null }; + } + const budgetDecision = catalogRunProgress?.budget_decision as JsonRecord | null | undefined; + const budgetState = String(budgetDecision?.state || "").trim().toLowerCase(); + const runbookState = + (catalogRunProgress?.runbook_state as JsonRecord | null | undefined) ?? + (budgetDecision?.runbook_state as JsonRecord | null | undefined); + const canaryOptional = + runbookState?.speed_canary_optional !== false && + getNumberFromRecord(runbookState, ["speed_canary_cap"]) === 4; + if (catalogLaunchActionsBlocked) { + return { allowed: false, reason: catalogLaunchGuardMessage || "Another catalog or comments run is active." }; + } + if (budgetState !== "normal" || !canaryOptional) { + return { allowed: false, reason: "Cap 4 canary is available only when the Instagram budget state is normal." }; + } + return { allowed: true, reason: null }; + }, [ + catalogLaunchActionsBlocked, + catalogLaunchGuardMessage, + catalogRunProgress?.budget_decision, + catalogRunProgress?.runbook_state, + platform, + ]); + + const runInstagramCap4Canary = useCallback(async () => { + if (platform !== "instagram") return; + if (!instagramCap4CanaryState.allowed) { + setCatalogActionMessage(instagramCap4CanaryState.reason); + return; + } + const dateStartIso = monthInputToWindowBoundaryIso("2025-01", "start"); + const dateEndIso = monthInputToWindowBoundaryIso("2025-12", "end"); + if (!dateStartIso || !dateEndIso) { + setCatalogActionMessage("Cannot build the 2025 cap 4 canary window."); + return; + } + const selectedTasks: CatalogBackfillSelectedTask[] = ["post_details", "comments", "media"]; + await runCatalogAction("backfill", { + ...buildBoundedWindowBackfillRequest(dateStartIso, dateEndIso, selectedTasks, 4, 4, true), + enable_cap4_canary: true, + }); + }, [ + buildBoundedWindowBackfillRequest, + instagramCap4CanaryState.allowed, + instagramCap4CanaryState.reason, + platform, + runCatalogAction, + ]); + + const confirmPendingInstagramApply = useCallback(async () => { + if (!pendingInstagramApply) { + return; + } + await runCatalogAction("backfill", { + ...pendingInstagramApply.requestBody, + apply_run_id: pendingInstagramApply.applyRunId, + operator_confirmation: pendingInstagramApply.requiredConfirmation, + }); + }, [pendingInstagramApply, runCatalogAction]); + const toggleInstagramBackfillTask = useCallback((task: CatalogBackfillSelectedTask) => { setInstagramBackfillSelectedTasks((current) => current.includes(task) ? current.filter((value) => value !== task) : [...current, task], @@ -8096,6 +9204,8 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } if (!user) return; const normalizedSourceId = String(sourceId || "").trim(); if (!normalizedSourceId) return; + const requestKey = `${platform}:${handle}:${normalizedSourceId}:${Date.now()}:${Math.random()}`; + catalogDetailRequestKeyRef.current = requestKey; setCatalogDetailSourceId(normalizedSourceId); setCatalogDetail(null); setCatalogDetailError(null); @@ -8113,12 +9223,16 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } throw buildSocialAccountRequestError(data, "Failed to load catalog post detail"); } const data = (await response.json().catch(() => ({}))) as SocialAccountCatalogPostDetail; + if (catalogDetailRequestKeyRef.current !== requestKey) return; setCatalogDetail(data); } catch (error) { + if (catalogDetailRequestKeyRef.current !== requestKey) return; const requestError = toSocialAccountRequestError(error, "Failed to load catalog post detail"); setCatalogDetailError(requestError.message); } finally { - setCatalogDetailLoading(false); + if (catalogDetailRequestKeyRef.current === requestKey) { + setCatalogDetailLoading(false); + } } }, [fetchAdminWithAuth, handle, platform, user], @@ -8261,6 +9375,10 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } let result = data.result ?? null; let status = data.status ?? "idle"; + const expectedOperationId = + typeof data.operation_id === "string" && data.operation_id.trim() + ? data.operation_id.trim() + : null; let attempts = 0; while (!result && (status === "queued" || status === "running") && attempts < 30) { @@ -8277,7 +9395,10 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } if (!statusResponse.ok) { throw buildSocialAccountRequestError(statusData, "Failed to fetch social account catalog gap analysis"); } - applyCatalogGapAnalysisStatus(statusData); + const applied = applyCatalogGapAnalysisStatus(statusData, expectedOperationId); + if (!applied) { + continue; + } result = statusData.result ?? null; status = statusData.status ?? "idle"; } @@ -8392,8 +9513,8 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } }; - const cancelCatalogRun = async () => { - const runId = String(cancellableCatalogRunId || "").trim(); + const cancelCatalogRun = async (overrideRunId?: string | null) => { + const runId = String(overrideRunId || cancellableCatalogRunId || "").trim(); if (!user || !runId) return; setCancellingCatalogRun(true); setCatalogActionMessage(null); @@ -8433,9 +9554,11 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } else { setCatalogActionMessage(cancelMessage); } + setPendingInstagramApply((current) => (current?.applyRunId === runId ? null : current)); } catch (error) { const reconciled = await reconcileCatalogRunAfterCancelAttempt(runId); if (reconciled) { + setPendingInstagramApply((current) => (current?.applyRunId === runId ? null : current)); return; } setCatalogActionMessage(error instanceof Error ? error.message : "Failed to cancel catalog run"); @@ -8529,6 +9652,48 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } } }; + const repairActiveCommentsAuth = async (requestBody: CatalogRepairAuthRequest = {}) => { + const runId = String(activeCommentsRunId || "").trim(); + if (!user || !runId || !activeCommentsRunCanRepairAuth) return; + if (!requestBody.operator_confirmation) { + setCommentsRepairAuthConfirmationRunId(runId); + setCatalogActionMessage(INSTAGRAM_AUTH_REFRESH_WARNING); + return; + } + setRepairingActiveCommentsAuth(true); + setCommentsRepairAuthConfirmationRunId(null); + setCatalogActionMessage(`Repairing Instagram comments auth for run ${shortRunId(runId)}...`); + try { + const response = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/runs/${encodeURIComponent(runId)}/repair-auth`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(requestBody), + }, + { preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as { + status?: string | null; + repair_status?: string | null; + error?: string; + message?: string; + detail?: { message?: string }; + }; + if (!response.ok) { + throw new Error(data.error || data.message || data.detail?.message || "Failed to repair comments auth"); + } + setCatalogActionMessage("Checking manual Instagram auth and resuming unfinished comments targets."); + await refreshProfileSnapshotNow().catch(() => {}); + refetchActiveCommentsRunProgress({ cause: "manual", forceRefresh: true }); + void fetchCookieHealth(true, { includeCommentsAuth: true }).catch(() => {}); + } catch (error) { + setCatalogActionMessage(error instanceof Error ? error.message : "Failed to repair comments auth"); + } finally { + setRepairingActiveCommentsAuth(false); + } + }; + const restartActiveCommentsRun = async () => { const runId = String(activeCommentsRunId || "").trim(); if (!user || !runId || !activeCommentsRunCanRestart) return; @@ -8864,7 +10029,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } const href = buildSocialAccountProfileUrl({ platform: sourcePlatform, handle: sourceHandle, - tab: selectedTab, + tab: getSupportedSocialAccountTab(sourcePlatform, selectedTab), }) as Route; const content = ( <> @@ -8941,6 +10106,17 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } > {runningCatalogAction === "backfill" ? "Preparing Backfill…" : "Backfill Posts"} + {platform === "instagram" ? ( + + ) : null} + +

+ + ) : null} {/* Cookie preflight card for cookie-dependent platforms */} {supportsCatalog && platformRequiresCookies ? (
@@ -9133,7 +10339,7 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab }
) : null}
-

Library Totals

+

Coverage

These are all-time saved records for this profile. Active backfill progress is shown separately below.

@@ -9232,6 +10438,174 @@ export default function SocialAccountProfilePage({ platform, handle, activeTab } ) : null}
+ {platform === "instagram" && selectedTab === "catalog" ? ( +
+
+
+

+ {displayedInstagramCompletionYear} Completion +

+

+ {instagramCompletionSummaryLoading + ? "Loading..." + : instagramCompletionSummary + ? `${formatInteger(instagramCompletionSummary.total_posts)} posts` + : instagramCompletionSummaryError || "Unavailable"} +

+
+ {instagramCompletionSummary ? ( +

+ {formatInteger(instagramCompletionSummary.total_reported_comments)} reported comments ·{" "} + {formatInteger(instagramCompletionSummary.saved_comments)} saved ·{" "} + {formatInteger(instagramCompletionSummary.missing_comments)} missing +

+ ) : null} +
+ {instagramCompletionSummary ? ( +
+ {instagramCompletionLanes.map((lane) => ( +
+

{lane.label}

+

+ {formatInteger(lane.summary.finished)} finished +

+

+ {formatInteger(lane.summary.in_progress)} in progress · {formatInteger(lane.summary.not_started)} not started +

+
+ ))} +
+ ) : null} +
+ {commentsStreamingBanner ? ( +
+
+
+

+ Comments Are Streaming From Saved Catalog Posts +

+

{commentsStreamingBanner.detail}

+
+
+ + {commentsStreamingBanner.stateLabel} + + {commentsStreamingBanner.runLabel ? ( + + {commentsStreamingBanner.runLabel} + + ) : null} +
+
+ {commentsStreamingBanner.metricItems.length > 0 ? ( +
+ {commentsStreamingBanner.metricItems.map((item) => ( + + {item} + + ))} +
+ ) : null} +
+

+ Next: {commentsStreamingBanner.nextActionLabel} +

+

{commentsStreamingBanner.nextActionDetail}

+
+ {commentsStreamingBanner.historyItems.length > 0 ? ( +
+

+ Recent Streaming Activity +

+
    + {commentsStreamingBanner.historyItems.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null} +
+ ) : null} +
+
+

+ Comments Stream After Posts Save +

+

+ Catalog saves posts first, comments can start as soon as those posts land, public-first recovery stays + the default, and auth-backed follow-up is only for leftover blocked gaps. +

+
+ {dashboardCommentsGapSourceLabel ? ( + + {dashboardCommentsGapSourceLabel} + + ) : null} +
+ {dashboardCommentsGapRows.length > 0 ? ( +
+

+ Posts waiting in the streaming comments lane +

+ + + + + + + + + + + + {dashboardCommentsGapRows.map((row) => ( + + + + + + + + ))} + +
PostGapSaved / reportedStateRecovery hint
+ {row.label} + + Public-first + + + {formatInteger(row.missingCommentGap)} + + {formatInteger(row.savedCommentCount)} / {formatInteger(row.reportedCommentCount)} + + {[row.status ? formatRunStatusLabel(row.status) : null, row.phase ? formatDiagnosticToken(row.phase) : null] + .filter(Boolean) + .join(" · ") || "Gap open"} + + {[ + row.reason ? formatDiagnosticToken(row.reason) : null, + row.hasTopLevelCursor ? "top cursor saved" : null, + row.replyResumeCount ? `${formatInteger(row.replyResumeCount)} replies queued` : null, + ] + .filter(Boolean) + .join(" · ") || "Awaiting next comments pass"} +
+
+ ) : ( +

+ No live gap rows are available right now. This card will show stored gap priorities between runs when the + completion summary includes them. +

+ )} +
+
+ ) : null} {pendingReviewOpen ? (
) : null} + {activeCommentsRunCanRepairAuth ? ( + + ) : null} {activeCommentsRunCanRestart ? (
- Page {catalogPosts?.pagination.page ?? catalogPage} of {catalogPosts?.pagination.total_pages ?? 1} + Page {catalogPosts?.pagination?.page ?? catalogPage} of {catalogPosts?.pagination?.total_pages ?? 1}
+ + +
+ + + ) : null} {!hashtagsLoading && hashtags.length > 0 ? (
{hashtags.map((item) => { const assignments = draftAssignments[item.hashtag] ?? []; + const canBulkSelect = hashtagAssignmentStatus === "unassigned" && assignments.length === 0; return (
-
-

{item.display_hashtag ?? `#${item.hashtag}`}

+
+ {canBulkSelect ? ( + toggleBulkSelectedHashtag(item.hashtag, event.target.checked)} + className="mt-1 h-4 w-4 rounded border-zinc-300 text-zinc-900" + /> + ) : null} +
+

{item.display_hashtag ?? `#${item.hashtag}`}

{formatInteger(item.usage_count)} uses · First seen {formatDateTime(item.first_seen_at)} · Last seen {formatDateTime(item.latest_seen_at)}

Observed on {(item.observed_shows ?? []).map((show) => show.show_name).filter(Boolean).join(", ") || "no assigned shows yet"}

+
+
+
+ ); + const statusRows = ( +
+ {entries.map((entry, index) => { + const key = entryKey(entry.personId, entry.handle); + const pending = pendingRefreshState[key]; + const memberHistory = historyByEntryKey.get(key) ?? []; + return ( +
0 ? "border-t border-zinc-100" : "" + }`} + > +
+
+ + @{entry.handle} + + {formatStatusLabel(entry.status)} + + {formatEntryDetail(entry, pending)} +
+
+ {formatTimingDetail(entry, pending, memberHistory)} +
+
+
+ {entry.status === "failed" ? ( + + ) : null} +
+
+ ); + })} +
+ ); + const historyPanel = ( +
+
+

+ Job History +

+ +
+
+ {historyError ? ( +

{historyError}

+ ) : recentHistoryItems.length === 0 ? ( +

No refresh history yet

+ ) : ( + recentHistoryItems.map((item) => ( +
+
+
+ @{item.handle ?? "unknown"} + + {item.status} + + {item.source ? {item.source} : null} +
+ {item.error || item.reason ? ( +

{item.error ?? item.reason}

+ ) : null} +
+ + {formatDateTimeLabel(item.scrapedAt) ?? "unknown time"} + +
+ )) + )} +
+
+ ); if (membersWithIG.length === 0) { return ( @@ -1312,12 +1878,12 @@ export default function CastSocialBladeComparison({ if (loadedEntries.length === 0) { return ( -
-

No SocialBlade data available

-

- Refresh SocialBlade here to backfill missing cast rows without visiting each person page. -

-
+
+
+

No SocialBlade data available

+

Queue a refresh to load cast rows.

+
+
)}
- {batchError && ( -

{batchError}

- )} -
- {entries.map((e) => ( -
+ {statusCountItems.length > 0 && ( +
+ {statusCountItems.map(({ status, count }) => ( - @{e.handle} - - {e.status} + key={status} + className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold ${statusChipClass(status)}`} + > + {formatStatusLabel(status)} + {count} - {e.error || formatScrapedAtLabel(e.data)} -
- ))} -
+ ))} +
+ )} + {statusRows} + {cookieHealthPanel} + {batchError && ( +

{batchError}

+ )} + {batchNotice && ( +
+ {batchNotice} +
+ )} + {historyPanel}
); } @@ -1388,31 +1960,32 @@ export default function CastSocialBladeComparison({
- {entries.map((entry) => ( -
( + - - @{entry.handle} - - {entry.status} - - {entry.error || formatScrapedAtLabel(entry.data)} -
+ {formatStatusLabel(status)} + {count} + ))}
+ {statusRows} + {cookieHealthPanel} {someLoading && ( Loading remaining... )} + {batchNotice && ( +
+ {batchNotice} +
+ )} {batchError && (
{batchError}
)} + {historyPanel}
{ - if (profile === "explicit-mapping-visual") { - return { - classification: 6, - role: 10, - width: 10, - weightCoverage: 7, - styleSupport: 4, - traitCompatibility: 3, - familyName: 5, - visualAffinity: 55, - riskPenalty: 20, - structuralTotal: 40, - identityTotal: 5, - visualTotal: 55, - penaltyTotal: 20, - }; - } - if (profile === "balanced-visual") { - return { - classification: 12, - role: 18, - width: 14, - weightCoverage: 10, - styleSupport: 6, - traitCompatibility: 5, - familyName: 10, - visualAffinity: 25, - riskPenalty: 20, - structuralTotal: 65, - identityTotal: 10, - visualTotal: 25, - penaltyTotal: 20, - }; - } - return { - classification: 17, - role: 25, - width: 19, - weightCoverage: 14, - styleSupport: 8, - traitCompatibility: 7, - familyName: 10, - visualAffinity: 0, - riskPenalty: 20, - structuralTotal: 90, - identityTotal: 10, - visualTotal: 0, - penaltyTotal: 20, - }; -} - function visualEvidenceTone(status: BrandFontMatchesApiResponse["visualEvidence"]["status"]): string { if (status === "fresh") return "bg-emerald-50 text-emerald-700 ring-emerald-200"; if (status === "stale") return "bg-amber-50 text-amber-800 ring-amber-200"; @@ -239,7 +181,7 @@ function sectionToneClass(tone: "zinc" | "emerald" | "sky" | "rose", negative = } function ScoreBreakdownBars({ breakdown }: { breakdown: ScoreBreakdown }) { - const maxima = profileMaxima(breakdown.profile); + const maxima = getScoreBreakdownMaxima(breakdown.profile); return (
@@ -248,7 +190,7 @@ function ScoreBreakdownBars({ breakdown }: { breakdown: ScoreBreakdown }) { Weighted score breakdown
- {profileLabel(breakdown.profile)} + {breakdown.profile}
diff --git a/apps/web/src/components/admin/instagram/InstagramCatalogBackfillMockupView.tsx b/apps/web/src/components/admin/instagram/InstagramCatalogBackfillMockupView.tsx new file mode 100644 index 00000000..f205633e --- /dev/null +++ b/apps/web/src/components/admin/instagram/InstagramCatalogBackfillMockupView.tsx @@ -0,0 +1,1269 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIcon, + AlertTriangleIcon, + ArrowLeftIcon, + CheckCircle2Icon, + Clock3Icon, + ExternalLinkIcon, + RefreshCwIcon, +} from "lucide-react"; +import Link from "next/link"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { fetchAdminWithAuth as fetchAdminWithAuthBase } from "@/lib/admin/client-auth"; +import { fetchSocialAccountCatalogRunProgressSnapshot } from "@/lib/admin/social-account-catalog-progress"; +import type { + SocialAccountCommentsRunProgress, + SocialAccountCommentsShardProgress, + SocialAccountCatalogRun, + SocialAccountCatalogRunProgressSnapshot, + SocialAccountOperationalAlert, + SocialPlatformSlug, +} from "@/lib/admin/social-account-profile"; +import { useAdminGuard } from "@/lib/admin/useAdminGuard"; + +const REFRESH_INTERVAL_MS = 5_000; +const COMPLETION_REFRESH_INTERVAL_MS = 60_000; +const ACTIVE_RUN_STATUSES = new Set(["queued", "pending", "retrying", "running", "cancelling"]); +const TERMINAL_MUTED_RUN_STATUSES = new Set(["cancelled", "failed"]); + +type ProxyErrorPayload = { + error?: string; + message?: string; + detail?: string | { message?: string }; +}; + +type MetricCard = { + label: string; + value: string; + detail: string; +}; + +type LaneRow = { + label: string; + status: string; + detail: string; +}; + +type TruthRow = { + key: string; + label: string; + value: string; + detail: string | null; + recommendation?: string | null; + progressValue?: number | null; +}; + +type IssueRow = { + key: string; + title: string; + detail: string; + recommendation?: string | null; + tone: "amber" | "red" | "sky"; +}; + +type ShardHealthSummary = { + failed: number; + retrying: number; + running: number; + queued: number; + complete: number; + cancelled: number; + total: number; + issueReasons: string[]; +}; + +type CatalogRecentRunsPayload = ProxyErrorPayload & { + catalog_recent_runs?: SocialAccountCatalogRun[]; +}; + +type CommentsProgressPayload = SocialAccountCommentsRunProgress & ProxyErrorPayload; + +type CompletionLaneSummary = { + finished?: number | null; + in_progress?: number | null; + not_started?: number | null; +}; + +type CompletionSummaryPayload = ProxyErrorPayload & { + year?: number; + total_posts?: number | null; + total_reported_comments?: number | null; + saved_comments?: number | null; + missing_comments?: number | null; + lanes?: { + comments?: CompletionLaneSummary; + details?: CompletionLaneSummary; + media?: CompletionLaneSummary; + }; +}; + +type InstagramCatalogBackfillMockupViewProps = { + platform: SocialPlatformSlug; + handle: string; + canonicalCatalogUrl: string; + variantLabel?: string; +}; + +const formatInteger = (value: number | null | undefined): string => { + const numeric = Number(value); + return new Intl.NumberFormat("en-US").format(Number.isFinite(numeric) ? numeric : 0); +}; + +const readFiniteNumber = (value: unknown): number | null => { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; +}; + +const readRecord = (value: unknown): Record | null => { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +}; + +const readString = (value: unknown): string | null => { + return typeof value === "string" && value.trim() ? value.trim() : null; +}; + +const readRecordNumber = (record: Record | null | undefined, keys: string[]): number | null => { + if (!record) return null; + for (const key of keys) { + const value = readFiniteNumber(record[key]); + if (value !== null) return value; + } + return null; +}; + +const firstFiniteNumber = (...values: unknown[]): number | null => { + for (const value of values) { + const numeric = readFiniteNumber(value); + if (numeric !== null) return numeric; + } + return null; +}; + +const formatStatusLabel = (value?: string | null): string => { + const normalized = String(value || "").trim(); + if (!normalized) return "Idle"; + return normalized + .replace(/_/g, " ") + .split(" ") + .filter(Boolean) + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(" "); +}; + +const shortRunId = (value?: string | null): string => { + const normalized = String(value || "").trim(); + return normalized ? normalized.slice(0, 8) : "none"; +}; + +const getCatalogRunIdentity = (run: SocialAccountCatalogRun): string => { + return String(run.run_id || run.job_id || "").trim().toLowerCase(); +}; + +const dedupeCatalogRuns = (runs: readonly SocialAccountCatalogRun[]): SocialAccountCatalogRun[] => { + const seen = new Set(); + const deduped: SocialAccountCatalogRun[] = []; + for (const run of runs) { + const identity = getCatalogRunIdentity(run); + if (!identity) { + deduped.push(run); + continue; + } + if (seen.has(identity)) continue; + seen.add(identity); + deduped.push(run); + } + return deduped; +}; + +const toProgressPercent = (completed: number | null, total: number | null): number => { + if (completed === null || total === null || total <= 0) return 0; + return Math.max(0, Math.min(100, (completed / total) * 100)); +}; + +const formatDecimal = (value: number | null, digits = 1): string | null => { + return value === null ? null : value.toFixed(digits); +}; + +const formatDurationEstimate = (seconds: number | null): string | null => { + if (seconds === null || seconds <= 0) return null; + const roundedMinutes = Math.max(1, Math.round(seconds / 60)); + const hours = Math.floor(roundedMinutes / 60); + const minutes = roundedMinutes % 60; + if (hours <= 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; +}; + +const formatDiagnosticToken = (value?: string | null): string => { + const normalized = String(value || "").trim(); + if (!normalized) return "Unknown"; + return normalized.replace(/_/g, " "); +}; + +const isRuntimeVersionAlertCode = (code?: string | null): boolean => { + const normalized = String(code || "").trim().toLowerCase(); + return normalized === "runtime_version_drift" || normalized === "runtime_version_pin_mismatch"; +}; + +const formatOperationalAlertLabel = (alert: SocialAccountOperationalAlert): string => { + const normalized = String(alert.code || "").trim().toLowerCase(); + if (normalized === "runtime_version_drift") return "Worker version drift"; + if (normalized === "runtime_version_pin_mismatch") return "Run is pinned to an older worker image"; + if (normalized === "failed_recovery_no_partitions_discovered") return "Failed recovery"; + return formatStatusLabel(normalized || alert.message || "alert"); +}; + +const buildRequestError = (payload: ProxyErrorPayload, fallback: string): Error => { + const detailMessage = typeof payload.detail === "object" ? payload.detail?.message : payload.detail; + return new Error(payload.error || payload.message || detailMessage || fallback); +}; + +const getActiveCatalogRun = (runs: readonly SocialAccountCatalogRun[]): SocialAccountCatalogRun | null => { + return ( + runs.find((run) => ACTIVE_RUN_STATUSES.has(String(run.status || "").trim().toLowerCase())) ?? + runs[0] ?? + null + ); +}; + +const resolveLaneStatus = (runStatus: string, laneStatus?: string | null): string => { + const normalizedRunStatus = runStatus.trim().toLowerCase(); + const normalizedLaneStatus = String(laneStatus || "").trim().toLowerCase(); + if (TERMINAL_MUTED_RUN_STATUSES.has(normalizedRunStatus) && ACTIVE_RUN_STATUSES.has(normalizedLaneStatus)) { + return normalizedRunStatus; + } + return normalizedLaneStatus || "waiting"; +}; + +const buildLaneRows = (progress: SocialAccountCatalogRunProgressSnapshot | null, runStatus: string): LaneRow[] => { + if (!progress) return []; + const summary = progress.summary ?? {}; + const normalizedRunStatus = runStatus.trim().toLowerCase(); + const runHasEnded = TERMINAL_MUTED_RUN_STATUSES.has(normalizedRunStatus); + const historicalLaneDetail = `Parent run is ${formatStatusLabel(normalizedRunStatus).toLowerCase()}; no lane is currently running.`; + const detailStatus = resolveLaneStatus(runStatus, progress.details_progress?.status ?? "waiting"); + const commentsStatus = resolveLaneStatus( + runStatus, + progress.attached_followups?.comments?.status ?? progress.comments_streaming?.state ?? "waiting", + ); + const mediaStatus = resolveLaneStatus(runStatus, progress.attached_followups?.media?.status ?? "waiting"); + return [ + { + label: "Catalog jobs", + status: formatStatusLabel(progress.run_status), + detail: `${formatInteger(summary.active_jobs)} active / ${formatInteger(summary.total_jobs)} total`, + }, + { + label: "Post details", + status: formatStatusLabel(detailStatus), + detail: + runHasEnded && ACTIVE_RUN_STATUSES.has(String(progress.details_progress?.status ?? "waiting").trim().toLowerCase()) + ? historicalLaneDetail + : progress.detail_worker_count != null + ? `${formatInteger(progress.detail_worker_count)} detail workers requested` + : "Details are tracked by the active catalog run", + }, + { + label: "Comments", + status: formatStatusLabel(commentsStatus), + detail: + runHasEnded && + ACTIVE_RUN_STATUSES.has( + String(progress.attached_followups?.comments?.status ?? progress.comments_streaming?.state ?? "waiting") + .trim() + .toLowerCase(), + ) + ? historicalLaneDetail + : progress.comments_run_id || progress.attached_followups?.comments?.run_id + ? `Run ${shortRunId(progress.comments_run_id ?? progress.attached_followups?.comments?.run_id)}` + : "Waiting for saved post targets", + }, + { + label: "Media", + status: formatStatusLabel(mediaStatus), + detail: + runHasEnded && + ACTIVE_RUN_STATUSES.has(String(progress.attached_followups?.media?.status ?? "waiting").trim().toLowerCase()) + ? historicalLaneDetail + : progress.attached_followups?.media?.enqueued_job_count != null + ? `${formatInteger(progress.attached_followups.media.enqueued_job_count)} repair jobs` + : "Media follows saved catalog posts", + }, + ]; +}; + +const laneTotal = (lane: CompletionLaneSummary | null | undefined): number | null => { + if (!lane) return null; + return ( + (readFiniteNumber(lane.finished) ?? 0) + + (readFiniteNumber(lane.in_progress) ?? 0) + + (readFiniteNumber(lane.not_started) ?? 0) + ); +}; + +const resolveCommentsRunId = ( + progress: SocialAccountCatalogRunProgressSnapshot | null | undefined, + run: SocialAccountCatalogRun | null | undefined, +): string | null => { + return ( + readString(progress?.comments_run_id) ?? + readString(progress?.comments_streaming?.comments_run_id) ?? + readString(progress?.attached_followups?.comments?.run_id) ?? + readString(run?.comments_run_id) ?? + readString(run?.attached_followups?.comments?.run_id) + ); +}; + +const resolveCommentsRunStatus = ( + progress: SocialAccountCatalogRunProgressSnapshot | null | undefined, + commentsProgress: SocialAccountCommentsRunProgress | null | undefined, + run: SocialAccountCatalogRun | null | undefined, +): string => { + return ( + readString(commentsProgress?.run_status) ?? + readString(progress?.attached_followups?.comments?.status) ?? + readString(progress?.attached_followups?.comments?.state) ?? + readString(progress?.comments_streaming?.state) ?? + readString(run?.attached_followups?.comments?.status) ?? + readString(run?.attached_followups?.comments?.state) ?? + "waiting" + ); +}; + +const getCommentsShardRows = ( + progress: SocialAccountCommentsRunProgress | null | undefined, +): SocialAccountCommentsShardProgress[] => { + if (!progress) return []; + return progress.comment_shards ?? progress.shards ?? progress.shard_progress ?? []; +}; + +const readReasonFromCounts = (counts: Record | null | undefined): string | null => { + if (!counts) return null; + const entry = Object.entries(counts).find(([, value]) => Number(value ?? 0) > 0); + return entry ? entry[0] : null; +}; + +const getShardIssueLabel = (row: SocialAccountCommentsShardProgress): string | null => { + return ( + readString(row.latest_failure_reason) ?? + readString(row.latest_stop_reason) ?? + readString(row.error_message) ?? + readReasonFromCounts(row.retry_reason_counts) ?? + readReasonFromCounts(row.stop_reason_counts) ?? + readReasonFromCounts(row.completion_reason_counts) + ); +}; + +const buildCommentsShardHealthSummary = ( + progress: SocialAccountCommentsRunProgress | null | undefined, + rows: readonly SocialAccountCommentsShardProgress[], +): ShardHealthSummary => { + const counters = progress?.worker_counters; + const issueReasons = new Set(); + for (const row of rows) { + const issue = getShardIssueLabel(row); + if (issue) issueReasons.add(formatDiagnosticToken(issue)); + } + if (counters) { + return { + failed: readFiniteNumber(counters.failed) ?? 0, + retrying: readFiniteNumber(counters.retrying) ?? 0, + running: readFiniteNumber(counters.running ?? counters.active) ?? 0, + queued: readFiniteNumber(counters.queued) ?? 0, + complete: readFiniteNumber(counters.completed) ?? 0, + cancelled: readFiniteNumber(counters.cancelled) ?? 0, + total: readFiniteNumber(counters.total) ?? rows.length, + issueReasons: Array.from(issueReasons).slice(0, 3), + }; + } + let failed = 0; + let retrying = 0; + let running = 0; + let queued = 0; + let complete = 0; + let cancelled = 0; + for (const row of rows) { + const status = String(row.status ?? row.job_status ?? "").trim().toLowerCase(); + if (status === "failed") failed += 1; + if (status === "retrying") retrying += 1; + if (status === "running") running += 1; + if (status === "queued" || status === "pending") queued += 1; + if (status === "completed" || status === "complete") complete += 1; + if (status === "cancelled" || status === "canceled") cancelled += 1; + } + return { + failed, + retrying, + running, + queued, + complete, + cancelled, + total: rows.length, + issueReasons: Array.from(issueReasons).slice(0, 3), + }; +}; + +const formatShardProgressLabel = (row: SocialAccountCommentsShardProgress, index: number): string => { + const shardIndex = readFiniteNumber(row.shard_index); + const shardCount = readFiniteNumber(row.shard_count); + const shardLabel = + shardIndex !== null && shardCount !== null + ? `Shard ${formatInteger(shardIndex + 1)} of ${formatInteger(shardCount)}` + : `Shard ${formatInteger(index + 1)}`; + const jobId = readString(row.job_id); + return jobId ? `${shardLabel} · job ${shortRunId(jobId)}` : shardLabel; +}; + +const formatShardPostLabel = (row: SocialAccountCommentsShardProgress): string => { + const checked = firstFiniteNumber( + row.processed_post_count, + row.completed_posts, + row.complete_posts, + row.matched_posts, + row.saved_posts, + ); + const total = firstFiniteNumber(row.target_count, row.target_source_ids_count, row.comments_shard_target_count); + if (checked !== null && total !== null) return `${formatInteger(checked)} / ${formatInteger(total)}`; + if (checked !== null) return formatInteger(checked); + if (total !== null) return `0 / ${formatInteger(total)}`; + return "Waiting"; +}; + +const formatShardCommentsLabel = (row: SocialAccountCommentsShardProgress): string => { + const commentsProcessed = firstFiniteNumber( + row.comments_processed, + row.comments_upserted, + row.comments_inserted, + row.items_found_total, + ); + const parts = [ + commentsProcessed !== null ? `${formatInteger(commentsProcessed)} fetched` : null, + formatDecimal(readFiniteNumber(row.posts_per_minute)) !== null + ? `${formatDecimal(readFiniteNumber(row.posts_per_minute))} posts/min` + : null, + formatDecimal(readFiniteNumber(row.comments_per_minute)) !== null + ? `${formatDecimal(readFiniteNumber(row.comments_per_minute))} comments/min` + : null, + ].filter(Boolean); + return parts.join(" · ") || "No comments yet"; +}; + +export function InstagramCatalogBackfillMockupView({ + platform, + handle, + canonicalCatalogUrl, + variantLabel = "Mockup", +}: InstagramCatalogBackfillMockupViewProps) { + const { user, checking, hasAccess } = useAdminGuard(); + const [recentRuns, setRecentRuns] = useState([]); + const [completionSummary, setCompletionSummary] = useState(null); + const [progress, setProgress] = useState(null); + const [commentsProgress, setCommentsProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [completionError, setCompletionError] = useState(null); + const [commentsProgressError, setCommentsProgressError] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + const hasLoadedRef = useRef(false); + const refreshInFlightRef = useRef(false); + const refreshRequestIdRef = useRef(0); + const completionInFlightRef = useRef(false); + const completionLastRequestedAtRef = useRef(0); + const normalizedPlatform = platform; + const normalizedHandle = handle.trim().toLowerCase().replace(/^@+/, ""); + const completionYear = new Date().getUTCFullYear(); + const fetchAdminWithAuth = useCallback( + ( + input: RequestInfo | URL, + init?: RequestInit, + options?: Parameters[2], + ) => + fetchAdminWithAuthBase(input, init, { + ...options, + preferredUser: options?.preferredUser ?? user, + allowDevAdminBypass: options?.allowDevAdminBypass ?? true, + }), + [user], + ); + + const refreshCompletionSummary = useCallback(async (options?: { force?: boolean }) => { + if (checking || !user || !hasAccess) return; + const now = Date.now(); + if (completionInFlightRef.current) return; + if (!options?.force && now - completionLastRequestedAtRef.current < COMPLETION_REFRESH_INTERVAL_MS) return; + completionInFlightRef.current = true; + completionLastRequestedAtRef.current = now; + try { + const completionResponse = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(normalizedPlatform)}/${encodeURIComponent( + normalizedHandle, + )}/completion-summary?year=${completionYear}`, + undefined, + { preferredUser: user }, + ); + const completionPayload = (await completionResponse.json().catch(() => ({}))) as CompletionSummaryPayload; + if (!completionResponse.ok) { + setCompletionSummary(null); + setCompletionError(buildRequestError(completionPayload, "2026 completion summary could not load").message); + } else { + setCompletionSummary(completionPayload); + setCompletionError(null); + } + } catch (caught) { + setCompletionSummary(null); + setCompletionError(caught instanceof Error ? caught.message : "2026 completion summary could not load"); + } finally { + completionInFlightRef.current = false; + } + }, [ + checking, + completionYear, + fetchAdminWithAuth, + hasAccess, + normalizedHandle, + normalizedPlatform, + user, + ]); + + const refreshDashboard = useCallback(async (options?: { showLoading?: boolean }) => { + if (checking || !user || !hasAccess) { + setLoading(false); + return; + } + if (refreshInFlightRef.current) return; + refreshInFlightRef.current = true; + const requestId = ++refreshRequestIdRef.current; + if (options?.showLoading || !hasLoadedRef.current) setLoading(true); + setError(null); + setCommentsProgressError(null); + const isCurrentRequest = () => refreshRequestIdRef.current === requestId; + try { + const recentRunsResponse = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(normalizedPlatform)}/${encodeURIComponent( + normalizedHandle, + )}/catalog/runs/recent?limit=8`, + undefined, + { preferredUser: user }, + ); + const recentRunsPayload = (await recentRunsResponse.json().catch(() => ({}))) as CatalogRecentRunsPayload; + if (!recentRunsResponse.ok) { + throw buildRequestError(recentRunsPayload, "Failed to load recent catalog runs"); + } + if (!isCurrentRequest()) return; + const nextRecentRuns = dedupeCatalogRuns(recentRunsPayload.catalog_recent_runs ?? []); + setRecentRuns(nextRecentRuns); + const activeRun = getActiveCatalogRun(nextRecentRuns); + const runId = String(activeRun?.run_id || "").trim(); + if (runId) { + const progressPayload = await fetchSocialAccountCatalogRunProgressSnapshot({ + fetchAdminWithAuth, + platform: normalizedPlatform, + handle: normalizedHandle, + runId, + preferredUser: user, + recentLogLimit: 8, + fast: true, + }); + if (!isCurrentRequest()) return; + setProgress(progressPayload); + const commentsRunId = resolveCommentsRunId(progressPayload, activeRun); + if (commentsRunId) { + try { + const commentsResponse = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(normalizedPlatform)}/${encodeURIComponent( + normalizedHandle, + )}/comments/runs/${encodeURIComponent(commentsRunId)}/progress`, + undefined, + { preferredUser: user }, + ); + const commentsPayload = (await commentsResponse.json().catch(() => ({}))) as CommentsProgressPayload; + if (!commentsResponse.ok) { + throw buildRequestError(commentsPayload, "Failed to load comments run progress"); + } + if (!isCurrentRequest()) return; + setCommentsProgress(commentsPayload); + setCommentsProgressError(null); + } catch (caught) { + if (!isCurrentRequest()) return; + setCommentsProgress(null); + setCommentsProgressError( + caught instanceof Error ? caught.message : "Failed to load comments run progress", + ); + } + } else { + setCommentsProgress(null); + setCommentsProgressError(null); + } + } else { + setProgress(null); + setCommentsProgress(null); + setCommentsProgressError(null); + } + if (!isCurrentRequest()) return; + setLastUpdatedAt(new Date()); + void refreshCompletionSummary({ force: Boolean(options?.showLoading) }); + } catch (caught) { + if (!isCurrentRequest()) return; + setError(caught instanceof Error ? caught.message : "Failed to load live catalog backfill data"); + } finally { + if (isCurrentRequest()) { + hasLoadedRef.current = true; + refreshInFlightRef.current = false; + setLoading(false); + } + } + }, [ + checking, + fetchAdminWithAuth, + hasAccess, + normalizedHandle, + normalizedPlatform, + refreshCompletionSummary, + user, + ]); + + useEffect(() => { + if (checking || !hasAccess || !user) return; + let cancelled = false; + const load = async () => { + if (cancelled) return; + await refreshDashboard(); + }; + void load(); + const intervalId = window.setInterval(() => { + void load(); + }, REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + refreshRequestIdRef.current += 1; + refreshInFlightRef.current = false; + window.clearInterval(intervalId); + }; + }, [checking, hasAccess, refreshDashboard, user]); + + const activeRun = getActiveCatalogRun(recentRuns); + const runId = String(progress?.run_id || activeRun?.run_id || "").trim(); + const runStatus = String(progress?.run_status || activeRun?.status || "").trim(); + const runIsActive = ACTIVE_RUN_STATUSES.has(runStatus.toLowerCase()); + const progressSummary = progress?.summary ?? {}; + const completedPosts = firstFiniteNumber( + progress?.post_progress?.completed_posts, + progress?.post_progress?.matched_posts, + progressSummary.items_found_total, + ); + const totalPosts = firstFiniteNumber( + progress?.post_progress?.total_posts, + progress?.source_total_posts_current, + completionSummary?.total_posts, + ); + const progressPercent = toProgressPercent(completedPosts, totalPosts); + const savedComments = firstFiniteNumber(completionSummary?.saved_comments); + const reportedComments = firstFiniteNumber(completionSummary?.total_reported_comments); + const commentGap = + firstFiniteNumber(completionSummary?.missing_comments) ?? + (savedComments !== null && reportedComments !== null ? Math.max(0, reportedComments - savedComments) : 0); + const detailsFinished = firstFiniteNumber(completionSummary?.lanes?.details?.finished); + const detailsTotal = laneTotal(completionSummary?.lanes?.details); + const mediaFinished = firstFiniteNumber(completionSummary?.lanes?.media?.finished); + const mediaTotal = laneTotal(completionSummary?.lanes?.media); + const activeWorkers = firstFiniteNumber( + progressSummary.active_jobs, + progress?.dispatch_health?.modal_running_unclaimed_jobs, + 0, + ); + const authMode = progress?.instagram_posts_auth_mode || progress?.posts_auth_mode || "not reported"; + const liveStatusDetail = runIsActive + ? `${formatInteger(activeWorkers)} workers active. Auth mode: ${formatStatusLabel(authMode)}.` + : runStatus + ? `Run is ${formatStatusLabel(runStatus).toLowerCase()}.` + : "No active catalog backfill run is reported."; + const runHasEnded = TERMINAL_MUTED_RUN_STATUSES.has(runStatus.toLowerCase()); + const commentsRunId = resolveCommentsRunId(progress, activeRun); + const commentsRunStatus = resolveCommentsRunStatus(progress, commentsProgress, activeRun); + const commentsShardRows = useMemo(() => getCommentsShardRows(commentsProgress), [commentsProgress]); + const commentsShardPreviewRows = useMemo(() => commentsShardRows.slice(0, 8), [commentsShardRows]); + const commentsShardHealth = useMemo( + () => buildCommentsShardHealthSummary(commentsProgress, commentsShardRows), + [commentsProgress, commentsShardRows], + ); + const commentsCompletedPosts = firstFiniteNumber( + commentsProgress?.post_progress?.completed_posts, + commentsProgress?.post_progress?.matched_posts, + readRecordNumber(commentsProgress?.summary, ["complete_posts_total"]), + ); + const commentsTotalPosts = firstFiniteNumber( + commentsProgress?.post_progress?.total_posts, + commentsProgress?.target_source_ids_count, + ); + const commentsProgressPercent = toProgressPercent(commentsCompletedPosts, commentsTotalPosts); + const commentsProcessed = firstFiniteNumber( + commentsProgress?.summary?.comments_processed_total, + commentsProgress?.summary?.items_found_total, + ); + const commentsPerMinute = readFiniteNumber(commentsProgress?.throughput?.comments_per_minute); + const postsPerMinute = readFiniteNumber(commentsProgress?.throughput?.posts_per_minute); + const commentsEta = formatDurationEstimate(readFiniteNumber(commentsProgress?.throughput?.estimated_seconds_remaining)); + const commentsThroughputLabel = [ + formatDecimal(postsPerMinute) !== null ? `${formatDecimal(postsPerMinute)} posts/min` : null, + formatDecimal(commentsPerMinute) !== null ? `${formatDecimal(commentsPerMinute)} comments/min` : null, + commentsEta ? `about ${commentsEta} remaining` : null, + ] + .filter(Boolean) + .join(" · "); + const lastUpdatedLabel = lastUpdatedAt ? lastUpdatedAt.toLocaleTimeString() : "Not loaded yet"; + + const coverageMetrics = useMemo( + () => [ + { + label: `${completionYear} posts`, + value: formatInteger(completionSummary?.total_posts), + detail: "Posts in the selected year", + }, + { + label: "Missing comments", + value: formatInteger(commentGap), + detail: "Backend gap count for the selected year", + }, + { + label: "Post details", + value: + detailsFinished !== null && detailsTotal !== null + ? `${formatInteger(detailsFinished)} / ${formatInteger(detailsTotal)}` + : formatInteger(detailsFinished), + detail: "Posts with saved details", + }, + { + label: "Media lane", + value: + mediaFinished !== null && mediaTotal !== null + ? `${formatInteger(mediaFinished)} / ${formatInteger(mediaTotal)}` + : formatInteger(mediaFinished), + detail: "Posts with completed media work", + }, + ], + [ + commentGap, + completionSummary?.total_posts, + completionYear, + detailsFinished, + detailsTotal, + mediaFinished, + mediaTotal, + ], + ); + const laneRows = useMemo(() => buildLaneRows(progress, runStatus), [progress, runStatus]); + const truthRows = useMemo(() => { + const rows: TruthRow[] = [ + { + key: "post-details", + label: "Post/details run", + value: runId ? `Run ${shortRunId(runId)} · ${formatStatusLabel(runStatus || "waiting")}` : "No run selected", + detail: + completedPosts !== null && totalPosts !== null + ? `${formatInteger(completedPosts)} / ${formatInteger(totalPosts)} posts checked. This is active-run progress, not the lifetime profile total.` + : liveStatusDetail, + recommendation: runHasEnded + ? "Treat this run as historical. Start a new run only from the live controls after checking why it ended." + : "Let the post/details workers continue; do not compare this checked count to all-time saved posts.", + progressValue: progressPercent, + }, + { + key: "comments", + label: "Comments run", + value: commentsRunId ? `Run ${shortRunId(commentsRunId)} · ${formatStatusLabel(commentsRunStatus)}` : "No comments run selected", + detail: [ + commentsCompletedPosts !== null && commentsTotalPosts !== null + ? `${formatInteger(commentsCompletedPosts)} / ${formatInteger(commentsTotalPosts)} posts checked` + : commentsRunId + ? "Waiting for comments worker counters" + : "Comments will appear here when the catalog run attaches a comments run", + commentsProcessed !== null ? `${formatInteger(commentsProcessed)} comments fetched this run` : null, + commentsThroughputLabel || null, + commentsShardHealth.total > 0 + ? `${formatInteger(commentsShardHealth.running)} running · ${formatInteger(commentsShardHealth.retrying)} retrying · ${formatInteger(commentsShardHealth.queued)} queued · ${formatInteger(commentsShardHealth.failed)} failed` + : null, + ] + .filter(Boolean) + .join(" · "), + recommendation: + commentsShardHealth.failed > 0 || commentsShardHealth.retrying > 0 + ? "Cancel or recover only the shard that is stuck; do not cancel the whole comments run while other shards can continue." + : commentsRunId + ? "Let comments continue separately from post/details work." + : "No comments action is available until a comments run is attached.", + progressValue: commentsRunId ? commentsProgressPercent : null, + }, + { + key: "auth", + label: "Auth state", + value: authMode === "not reported" ? "Not reported" : formatStatusLabel(authMode), + detail: [ + `Post/details auth mode: ${formatStatusLabel(authMode)}`, + commentsRunId ? "comments progress feed is connected" : "comments progress feed is waiting for a run ID", + ].join(". "), + recommendation: + authMode === "not reported" + ? "Watch worker errors before treating auth as blocked." + : "No auth action is needed from this read-only page.", + }, + { + key: "completion", + label: `${completionYear} completion`, + value: `${formatInteger(completionSummary?.total_posts)} posts · ${formatInteger(commentGap)} missing comments`, + detail: + savedComments !== null && reportedComments !== null + ? `${formatInteger(savedComments)} saved comments / ${formatInteger(reportedComments)} reported comments. This is coverage for ${completionYear}, not current worker progress.` + : "Coverage totals load separately from active worker progress.", + recommendation: "Use this for the remaining gap; use active run cards for what is happening right now.", + }, + ]; + return rows; + }, [ + authMode, + commentGap, + commentsCompletedPosts, + commentsProcessed, + commentsProgressPercent, + commentsRunId, + commentsRunStatus, + commentsShardHealth.failed, + commentsShardHealth.queued, + commentsShardHealth.retrying, + commentsShardHealth.running, + commentsShardHealth.total, + commentsThroughputLabel, + commentsTotalPosts, + completedPosts, + completionSummary?.total_posts, + completionYear, + liveStatusDetail, + progressPercent, + reportedComments, + runHasEnded, + runId, + runStatus, + savedComments, + totalPosts, + ]); + + const issueRows = useMemo(() => { + const rows: IssueRow[] = []; + if (error) { + rows.push({ + key: "catalog-refresh-error", + title: "Live catalog data could not load", + detail: error, + recommendation: "Keep the active run separate from this read error; reload only the dashboard data.", + tone: "red", + }); + } + if (progress?.progress_degraded) { + rows.push({ + key: "catalog-progress-degraded", + title: "Post/details progress is using the last good update", + detail: `Last good update${progress.progress_degraded_at ? ` from ${new Date(progress.progress_degraded_at).toLocaleString()}` : ""}${progress.progress_degraded_reason ? `: ${formatDiagnosticToken(progress.progress_degraded_reason)}` : ""}.`, + recommendation: "Do not start a duplicate run; wait for a fresh worker heartbeat or inspect the stuck job.", + tone: "amber", + }); + } + const dispatchHealth = progress?.dispatch_health; + const dispatchBlocked = readFiniteNumber(dispatchHealth?.dispatch_blocked_jobs) ?? 0; + const dispatchQueued = + (readFiniteNumber(dispatchHealth?.queued_unclaimed_jobs) ?? 0) + + (readFiniteNumber(dispatchHealth?.modal_pending_jobs) ?? 0) + + (readFiniteNumber(dispatchHealth?.retrying_dispatch_jobs) ?? 0) + + (readFiniteNumber(dispatchHealth?.stale_dispatch_failed_jobs) ?? 0); + if (dispatchBlocked > 0 || dispatchQueued > 0 || dispatchHealth?.latest_dispatch_error) { + rows.push({ + key: "catalog-dispatch", + title: dispatchBlocked > 0 ? "Post/details dispatch is blocked" : "Post/details dispatch is retrying", + detail: + dispatchHealth?.latest_dispatch_error || + `${formatInteger(dispatchQueued)} queued or retrying dispatch jobs; ${formatInteger(dispatchBlocked)} blocked jobs.`, + recommendation: "Clear the stuck post/details queue before launching more post/detail work.", + tone: dispatchBlocked > 0 ? "red" : "amber", + }); + } + for (const alert of (progress?.alerts ?? []).slice(0, 3)) { + const runtimeVersionAlert = isRuntimeVersionAlertCode(alert.code); + rows.push({ + key: `catalog-alert-${alert.code}-${rows.length}`, + title: formatOperationalAlertLabel(alert), + detail: + runtimeVersionAlert && String(alert.code).toLowerCase() === "runtime_version_drift" + ? "More than one worker runtime has reported into this run. The run can keep saving progress, but behavior may differ until workers converge." + : runtimeVersionAlert + ? "This run is still using the runtime it started with. Requeue only if the newest worker image is required now." + : alert.message, + recommendation: runtimeVersionAlert + ? "Cancel and requeue only when you need the current worker image immediately. Saved rows stay saved." + : "Use worker details to decide whether this is transient or needs a targeted retry.", + tone: alert.severity === "error" ? "red" : alert.severity === "info" ? "sky" : "amber", + }); + } + if (runHasEnded && (activeRun?.error_message || progress?.completion_gap_reason)) { + rows.push({ + key: "catalog-ended", + title: `Run ${formatStatusLabel(runStatus).toLowerCase()}`, + detail: activeRun?.error_message || progress?.completion_gap_reason || "Run ended.", + recommendation: "Treat attached pending lanes as historical for this run.", + tone: runStatus.toLowerCase() === "failed" ? "red" : "amber", + }); + } + if (commentsProgressError) { + rows.push({ + key: "comments-progress-error", + title: "Comments progress poll is retrying", + detail: commentsProgressError, + recommendation: "Wait for the next comments progress refresh before cancelling shards.", + tone: "amber", + }); + } + const commentsWarning = readString(commentsProgress?.warning_message); + if (commentsWarning) { + rows.push({ + key: "comments-warning", + title: "Comments warning", + detail: commentsWarning, + recommendation: commentsWarning.toLowerCase().includes("blocked") + ? "Repair comments auth before expecting more comments to save." + : "Let comments continue unless the warning repeats without saved-count movement.", + tone: commentsWarning.toLowerCase().includes("blocked") ? "red" : "amber", + }); + } + if (commentsShardHealth.failed > 0 || commentsShardHealth.retrying > 0) { + rows.push({ + key: "comments-shards", + title: "Comments shards need attention", + detail: [ + commentsShardHealth.failed > 0 ? `${formatInteger(commentsShardHealth.failed)} failed` : null, + commentsShardHealth.retrying > 0 ? `${formatInteger(commentsShardHealth.retrying)} retrying` : null, + commentsShardHealth.issueReasons.length > 0 + ? `latest reasons: ${commentsShardHealth.issueReasons.join(", ")}` + : null, + "The comments run can still continue while individual shards retry.", + ] + .filter(Boolean) + .join(". "), + recommendation: "Cancel only a shard that is stale or no longer moving; failed or retrying shards do not mean the whole comments run is dead.", + tone: commentsShardHealth.failed > 0 ? "red" : "amber", + }); + } + return rows.slice(0, 6); + }, [ + activeRun?.error_message, + commentsProgress?.warning_message, + commentsProgressError, + commentsShardHealth.failed, + commentsShardHealth.issueReasons, + commentsShardHealth.retrying, + error, + progress, + runHasEnded, + runStatus, + ]); + + const recentHistory = useMemo(() => { + const activeId = String(runId || "").trim().toLowerCase(); + return dedupeCatalogRuns(recentRuns) + .filter((run) => getCatalogRunIdentity(run) !== activeId) + .slice(0, 4); + }, [recentRuns, runId]); + + return ( +
+
+
+
+ +
+
+ + +
+
+ + {completionError && !error ? ( + + + ) : null} + +
+
+
+

Current Truth

+

Instagram Backfill Status

+

+ Active run progress is the source of truth for what is running now. Coverage totals and old runs are + context, not live worker movement. +

+
+ + @{normalizedHandle} + +
+ +
+ {truthRows.map((row) => ( +
+
+
+

{row.label}

+

{row.value}

+
+ {typeof row.progressValue === "number" ? ( + + {Math.round(row.progressValue)}% + + ) : null} +
+ {typeof row.progressValue === "number" ? ( + + ) : null} + {row.detail ?

{row.detail}

: null} + {row.recommendation ? ( +

+ Recommended action: {row.recommendation} +

+ ) : null} +
+ ))} +
+ +
+
+ +
+
+

What is going wrong

+ 0 ? "bg-amber-100 text-amber-800" : "bg-emerald-100 text-emerald-800" + }`} + > + {issueRows.length > 0 ? `${issueRows.length} item${issueRows.length === 1 ? "" : "s"}` : "No blocker"} + +
+ {issueRows.length > 0 ? ( +
+ {issueRows.map((issue) => ( +
+

{issue.title}

+

{issue.detail}

+ {issue.recommendation ? ( +

+ Recommended action: {issue.recommendation} +

+ ) : null} +
+ ))} +
+ ) : ( +
+
+ )} +
+
+ +
+ {coverageMetrics.map((metric) => ( + + + {metric.label} + {metric.value} + + {metric.detail} + + ))} +
+ +
+ + + Shard Health + + Comments worker health from the attached comments run. Shards sit below the primary live status. + + + +
+ {formatInteger(commentsShardHealth.running)} running + {formatInteger(commentsShardHealth.retrying)} retrying + {formatInteger(commentsShardHealth.queued)} queued + {formatInteger(commentsShardHealth.failed)} failed +
+ {commentsProgressError ? ( +

Comments shard details are retrying: {commentsProgressError}

+ ) : commentsShardPreviewRows.length > 0 ? ( + + + + Shard + Status + Posts + Comments / speed + Latest issue + + + + {commentsShardPreviewRows.map((row, index) => ( + + {formatShardProgressLabel(row, index)} + + {formatStatusLabel(row.status ?? row.job_status)} + + {formatShardPostLabel(row)} + {formatShardCommentsLabel(row)} + + {getShardIssueLabel(row) ? formatDiagnosticToken(getShardIssueLabel(row)) : "None"} + + + ))} + +
+ ) : commentsRunId ? ( +

+ Comments run {shortRunId(commentsRunId)} is connected, but shard rows have not been reported yet. +

+ ) : ( +

No comments run is attached yet.

+ )} +
+
+ + + + Worker and Lane Details + Lower-priority catalog, details, comments, and media execution state. + + + {laneRows.length === 0 ? ( +

No active lane details yet.

+ ) : ( + + + + Lane + Status + Detail + + + + {laneRows.map((row) => ( + + {row.label} + + {row.status} + + {row.detail} + + ))} + +
+ )} +
+
+
+ + + + History + Current run is shown above, not repeated here. + + + {recentHistory.length === 0 ? ( +

No older catalog runs need attention.

+ ) : ( + recentHistory.map((run) => ( +
+
+

Run {shortRunId(run.run_id)}

+ {formatStatusLabel(run.status)} +
+

+ {run.created_at ? `Queued ${new Date(run.created_at).toLocaleString()}` : "Queued time not reported"} +

+
+ )) + )} +
+
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/components/admin/instagram/InstagramCommentsPanel.tsx b/apps/web/src/components/admin/instagram/InstagramCommentsPanel.tsx index 59eb010b..7adffc61 100644 --- a/apps/web/src/components/admin/instagram/InstagramCommentsPanel.tsx +++ b/apps/web/src/components/admin/instagram/InstagramCommentsPanel.tsx @@ -23,13 +23,14 @@ import { import type { CatalogBackfillLaunchResponse, CatalogBackfillRequest, - SocialAccountCommentsAuditCursorRetriesResponse, - SocialAccountCommentsAuditCursorRetryRequest, - SocialAccountCommentsAuditCursorRetryRow, + SocialAccountCommentsRelayCheckpointRetriesResponse, + SocialAccountCommentsRelayCheckpointRetryRequest, + SocialAccountCommentsRelayCheckpointRetryRow, SocialAccountCommentsCancelResponse, SocialAccountCommentsNetworkSpend, SocialAccountCommentsRunProgress, SocialAccountCommentsShardProgress, + SocialAccountCommentsDryRunPreviewResponse, SocialAccountCommentsScrapeRequest, SocialAccountCommentsScrapeResponse, SocialAccountCommentsTargetProgressRow, @@ -96,6 +97,14 @@ type CommentsRunHealth = { largestGaps: CommentsHealthGap[]; recommendedAction: string | null; }; +type IncompleteFillPreviewSummary = { + targetCount: number; + shardCount: number; + strategy: string; + windowLabel: string; + sampleShortcodes: string[]; + warningCount: number; +}; const ACTIVE_RUN_STATUSES = new Set(["queued", "pending", "retrying", "running", "cancelling"]); const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]); @@ -412,14 +421,30 @@ const formatCommentsProgressMessage = (progress: SocialAccountCommentsRunProgres const formatCommentsTerminalError = (progress: SocialAccountCommentsRunProgress, status: string): string => { const baseMessage = progress.error_message || `Comments sync ${status}.`; + if (status === "cancelled") { + const cleanBaseMessage = baseMessage.toLowerCase().includes("cancelled by user request") + ? "Comments sync cancelled." + : baseMessage; + const remainingTargets = readFiniteNumber(progress.cancellation_summary?.remaining_target_source_ids_count) ?? 0; + if (progress.cancellation_summary?.resume_recommendation === "stale_or_missing") { + const resumeHint = + remainingTargets > 0 + ? `${formatInteger(remainingTargets)} targets remain; use Incomplete Fill to continue only unfinished posts.` + : "Use Incomplete Fill to resume unfinished posts."; + return `${cleanBaseMessage} ${resumeHint}`; + } + return remainingTargets > 0 + ? `${cleanBaseMessage} ${formatInteger(remainingTargets)} targets were left unfinished; use Incomplete Fill to continue only unfinished posts.` + : cleanBaseMessage; + } if (progress.cancellation_summary?.resume_recommendation !== "stale_or_missing") { return baseMessage; } const remainingTargets = readFiniteNumber(progress.cancellation_summary.remaining_target_source_ids_count) ?? 0; const resumeHint = remainingTargets > 0 - ? `${formatInteger(remainingTargets)} targets remain; run Sync Comments to resume.` - : "Run Sync Comments to resume."; + ? `${formatInteger(remainingTargets)} targets remain; use Incomplete Fill to continue only unfinished posts.` + : "Use Incomplete Fill to resume unfinished posts."; return `${baseMessage} ${resumeHint}`; }; @@ -602,6 +627,45 @@ const formatCommentsLaunchQueuedMessage = ( return fallback; }; +const getIncompleteFillPreviewSummary = ( + data: SocialAccountCommentsDryRunPreviewResponse, +): IncompleteFillPreviewSummary => { + const targetCount = readFiniteNumber(data.target_source_ids_count) ?? 0; + const shardCount = readFiniteNumber(data.comments_shard_count) ?? 0; + const strategy = formatCompactReason( + String(data.comments_load_strategy || data.comments_session_scope || "public_relay"), + ); + const windowStart = data.date_start ?? data.target_window?.date_start ?? null; + const windowEnd = data.date_end ?? data.target_window?.date_end ?? null; + const windowLabel = + windowStart || windowEnd + ? `window ${windowStart ?? "start"} to ${windowEnd ?? "open end"}` + : "all saved dates"; + const sampleShortcodes = (data.sample_target_source_ids ?? []).filter(Boolean).slice(0, 5); + const warningCount = Array.isArray(data.strategy_warnings) ? data.strategy_warnings.length : 0; + return { + targetCount, + shardCount, + strategy, + windowLabel, + sampleShortcodes, + warningCount, + }; +}; + +const formatIncompleteFillPreviewMessage = (data: SocialAccountCommentsDryRunPreviewResponse): string => { + const summary = getIncompleteFillPreviewSummary(data); + return [ + `Incomplete Fill preview: ${formatInteger(summary.targetCount)} targets`, + summary.shardCount > 0 ? `${formatInteger(summary.shardCount)} shard${summary.shardCount === 1 ? "" : "s"}` : null, + summary.strategy, + summary.windowLabel, + summary.sampleShortcodes.length ? `sample ${summary.sampleShortcodes.join(", ")}` : null, + ] + .filter(Boolean) + .join(" / "); +}; + const readProgressBoolean = (value: unknown): boolean | null => { return typeof value === "boolean" ? value : null; }; @@ -708,19 +772,23 @@ export default function InstagramCommentsPanel({ const [scrapeMessage, setScrapeMessage] = useState(null); const [scrapeError, setScrapeError] = useState(null); const [scrapeRunId, setScrapeRunId] = useState(null); + const [incompleteFillPreview, setIncompleteFillPreview] = + useState(null); const [commentsLaunchPending, setCommentsLaunchPending] = useState(false); + const [commentsPreviewPending, setCommentsPreviewPending] = useState(false); const [cancelPending, setCancelPending] = useState(false); const [cancelRequestedRunId, setCancelRequestedRunId] = useState(null); const [catalogRefreshPending, setCatalogRefreshPending] = useState(false); - const [auditCursorRecovery, setAuditCursorRecovery] = - useState(null); - const [auditCursorRecoveryLoading, setAuditCursorRecoveryLoading] = useState(false); - const [auditCursorRecoveryPending, setAuditCursorRecoveryPending] = useState(false); - const [auditCursorRerunShortcode, setAuditCursorRerunShortcode] = useState(null); - const [auditCursorRecoveryError, setAuditCursorRecoveryError] = useState(null); - const [auditCursorShowFilter, setAuditCursorShowFilter] = useState(""); + const [relayCheckpointRecovery, setRelayCheckpointRecovery] = + useState(null); + const [relayCheckpointRecoveryLoading, setRelayCheckpointRecoveryLoading] = useState(false); + const [relayCheckpointRecoveryPending, setRelayCheckpointRecoveryPending] = useState(false); + const [relayCheckpointRerunShortcode, setRelayCheckpointRerunShortcode] = useState(null); + const [relayCheckpointRecoveryError, setRelayCheckpointRecoveryError] = useState(null); + const [relayCheckpointShowFilter, setRelayCheckpointShowFilter] = useState(""); const [selectedPost, setSelectedPost] = useState(null); const [modalRefreshKey, setModalRefreshKey] = useState(0); + const manuallyClosedPostSourceIdRef = useRef(null); const handledTerminalRunRef = useRef(null); const terminalCoverageRefreshRunRef = useRef(null); const mountedAtMsRef = useRef(Date.now()); @@ -798,13 +866,13 @@ export default function InstagramCommentsPanel({ void refreshPosts(); }, [refreshPosts, supportsComments]); - const refreshAuditCursorRecovery = useCallback(async () => { + const refreshRelayCheckpointRecovery = useCallback(async () => { if (checking || !user || !hasAccess || !supportsInlineCommentsSync) return; - setAuditCursorRecoveryLoading(true); - setAuditCursorRecoveryError(null); + setRelayCheckpointRecoveryLoading(true); + setRelayCheckpointRecoveryError(null); try { const params = new URLSearchParams({ limit: "50" }); - const trimmedShowFilter = auditCursorShowFilter.trim(); + const trimmedShowFilter = relayCheckpointShowFilter.trim(); if (trimmedShowFilter) { params.set("show_filter", trimmedShowFilter); } @@ -813,23 +881,23 @@ export default function InstagramCommentsPanel({ undefined, { preferredUser: user }, ); - const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsAuditCursorRetriesResponse & + const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsRelayCheckpointRetriesResponse & ProxyErrorPayload; if (!response.ok) { - throw new Error(readInstagramCommentsErrorMessage(data, "Failed to load audit cursor recovery")); + throw new Error(readInstagramCommentsErrorMessage(data, "Failed to load relay checkpoint recovery")); } - setAuditCursorRecovery(data); + setRelayCheckpointRecovery(data); } catch (error) { - setAuditCursorRecoveryError(error instanceof Error ? error.message : "Failed to load audit cursor recovery"); + setRelayCheckpointRecoveryError(error instanceof Error ? error.message : "Failed to load relay checkpoint recovery"); } finally { - setAuditCursorRecoveryLoading(false); + setRelayCheckpointRecoveryLoading(false); } - }, [auditCursorShowFilter, checking, fetchAdminWithAuth, handle, hasAccess, platform, supportsInlineCommentsSync, user]); + }, [relayCheckpointShowFilter, checking, fetchAdminWithAuth, handle, hasAccess, platform, supportsInlineCommentsSync, user]); useEffect(() => { if (!supportsInlineCommentsSync) return; - void refreshAuditCursorRecovery(); - }, [refreshAuditCursorRecovery, supportsInlineCommentsSync]); + void refreshRelayCheckpointRecovery(); + }, [refreshRelayCheckpointRecovery, supportsInlineCommentsSync]); useEffect(() => { if (!selectedPost || !posts) return; @@ -845,6 +913,7 @@ export default function InstagramCommentsPanel({ const sourceId = String(selectedPostParam || "").trim(); if (!sourceId || !posts) return; if (selectedPost?.source_id === sourceId) return; + if (manuallyClosedPostSourceIdRef.current === sourceId) return; const postFromUrl = posts.items.find((item) => item.source_id === sourceId); if (postFromUrl) { setSelectedPost(postFromUrl); @@ -870,18 +939,69 @@ export default function InstagramCommentsPanel({ }); const refetchRunProgress = runProgress.refetch; + const buildPublicRelayCommentsScrapeBody = useCallback( + (options: { target_filter?: "incomplete"; dry_run?: boolean } = {}): SocialAccountCommentsScrapeRequest => { + const body: SocialAccountCommentsScrapeRequest = { + mode: "profile", + source_scope: "network", + refresh_policy: "stale_or_missing", + max_comments_per_post: 0, + comments_load_strategy: "public_relay", + date_start: searchParams.get("date_start"), + date_end: searchParams.get("date_end"), + }; + if (options.target_filter) { + body.target_filter = options.target_filter; + } + if (options.dry_run) { + body.dry_run = true; + } + return body; + }, + [searchParams], + ); + + const previewIncompleteFillScrape = useCallback(async () => { + if (!user) return; + setScrapeError(null); + setScrapeMessage("Previewing public relay incomplete fill..."); + setIncompleteFillPreview(null); + setCommentsPreviewPending(true); + const body = buildPublicRelayCommentsScrapeBody({ target_filter: "incomplete", dry_run: true }); + try { + const response = await fetchAdminWithAuth( + `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/scrape`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + { preferredUser: user }, + ); + const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsDryRunPreviewResponse & + ProxyErrorPayload; + if (!response.ok) { + throw new Error(readInstagramCommentsErrorMessage(data, "Failed to preview incomplete fill")); + } + setIncompleteFillPreview(data); + setScrapeMessage(formatIncompleteFillPreviewMessage(data)); + } catch (error) { + setIncompleteFillPreview(null); + setScrapeError(error instanceof Error ? error.message : "Failed to preview incomplete fill"); + } finally { + setCommentsPreviewPending(false); + } + }, [buildPublicRelayCommentsScrapeBody, fetchAdminWithAuth, handle, platform, user]); + const startProfileScrape = useCallback(async () => { if (!user) return; setScrapeError(null); - setScrapeMessage("Repairing Instagram auth if needed..."); + setIncompleteFillPreview(null); + setScrapeMessage("Preparing public comments relay sync..."); setCommentsLaunchPending(true); handledTerminalRunRef.current = null; setCancelRequestedRunId(null); - const body: SocialAccountCommentsScrapeRequest = { - mode: "profile", - source_scope: "network", - refresh_policy: "stale_or_missing", - }; + const body = buildPublicRelayCommentsScrapeBody(); try { const response = await fetchAdminWithAuth( `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/scrape`, @@ -917,21 +1037,17 @@ export default function InstagramCommentsPanel({ } finally { setCommentsLaunchPending(false); } - }, [fetchAdminWithAuth, handle, platform, user]); + }, [buildPublicRelayCommentsScrapeBody, fetchAdminWithAuth, handle, platform, user]); const startIncompleteFillScrape = useCallback(async () => { if (!user) return; setScrapeError(null); - setScrapeMessage("Repairing Instagram auth if needed..."); + setIncompleteFillPreview(null); + setScrapeMessage("Preparing public comments relay incomplete fill..."); setCommentsLaunchPending(true); handledTerminalRunRef.current = null; setCancelRequestedRunId(null); - const body: SocialAccountCommentsScrapeRequest = { - mode: "profile", - source_scope: "network", - refresh_policy: "stale_or_missing", - target_filter: "incomplete", - }; + const body = buildPublicRelayCommentsScrapeBody({ target_filter: "incomplete" }); try { const response = await fetchAdminWithAuth( `/api/admin/trr-api/social/profiles/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/comments/scrape`, @@ -967,23 +1083,26 @@ export default function InstagramCommentsPanel({ } finally { setCommentsLaunchPending(false); } - }, [fetchAdminWithAuth, handle, platform, user]); + }, [buildPublicRelayCommentsScrapeBody, fetchAdminWithAuth, handle, platform, user]); - const startAuditCursorRecovery = useCallback(async () => { + const startRelayCheckpointRecovery = useCallback(async () => { if (!user) return; setScrapeError(null); - setAuditCursorRecoveryError(null); - setScrapeMessage("Queueing cap-free audit cursor recovery..."); - setAuditCursorRecoveryPending(true); - const body: SocialAccountCommentsAuditCursorRetryRequest = { + setIncompleteFillPreview(null); + setRelayCheckpointRecoveryError(null); + setScrapeMessage("Queueing public relay checkpoint recovery..."); + setRelayCheckpointRecoveryPending(true); + const body: SocialAccountCommentsRelayCheckpointRetryRequest = { limit: 50, batch_size: 1, max_comments_per_post: 0, - comments_load_strategy: "cursor_api", + comments_load_strategy: "public_relay", + date_start: searchParams.get("date_start"), + date_end: searchParams.get("date_end"), attach_to_active_run: true, dispatch_immediately: true, }; - const trimmedShowFilter = auditCursorShowFilter.trim(); + const trimmedShowFilter = relayCheckpointShowFilter.trim(); if (trimmedShowFilter) { body.show_filter = trimmedShowFilter; } @@ -997,12 +1116,12 @@ export default function InstagramCommentsPanel({ }, { preferredUser: user }, ); - const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsAuditCursorRetriesResponse & + const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsRelayCheckpointRetriesResponse & ProxyErrorPayload; if (!response.ok || data.ok === false) { - throw new Error(readInstagramCommentsErrorMessage(data, "Failed to queue audit cursor recovery")); + throw new Error(readInstagramCommentsErrorMessage(data, "Failed to queue relay checkpoint recovery")); } - setAuditCursorRecovery(data); + setRelayCheckpointRecovery(data); const runId = readCommentsRunId(data.enqueue?.result) ?? readCommentsRunId(data.active_run) ?? @@ -1015,48 +1134,52 @@ export default function InstagramCommentsPanel({ const modeLabel = data.enqueue?.mode === "active_run_split" ? "split into the active run" : "queued"; setScrapeMessage( createdCount > 0 - ? `Audit cursor recovery ${modeLabel}. ${formatInteger(createdCount)} batch jobs for ${formatInteger(selectedCount)} targets.` - : `Audit cursor recovery ${modeLabel}. ${formatInteger(selectedCount)} targets selected.`, + ? `Relay checkpoint recovery ${modeLabel}. ${formatInteger(createdCount)} batch jobs for ${formatInteger(selectedCount)} targets.` + : `Relay checkpoint recovery ${modeLabel}. ${formatInteger(selectedCount)} targets selected.`, ); void refreshPosts(); - void refreshAuditCursorRecovery(); + void refreshRelayCheckpointRecovery(); void onSummaryRefresh?.(); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to queue audit cursor recovery"; - setAuditCursorRecoveryError(message); + const message = error instanceof Error ? error.message : "Failed to queue relay checkpoint recovery"; + setRelayCheckpointRecoveryError(message); setScrapeError(message); } finally { - setAuditCursorRecoveryPending(false); + setRelayCheckpointRecoveryPending(false); } }, [ - auditCursorShowFilter, + relayCheckpointShowFilter, fetchAdminWithAuth, handle, onSummaryRefresh, platform, - refreshAuditCursorRecovery, + refreshRelayCheckpointRecovery, refreshPosts, + searchParams, user, ]); - const rerunAuditCursorTarget = useCallback( - async (row: SocialAccountCommentsAuditCursorRetryRow) => { + const rerunRelayCheckpointTarget = useCallback( + async (row: SocialAccountCommentsRelayCheckpointRetryRow) => { if (!user || !row.shortcode) return; setScrapeError(null); - setAuditCursorRecoveryError(null); + setIncompleteFillPreview(null); + setRelayCheckpointRecoveryError(null); setScrapeMessage(`Queueing ${row.shortcode} now...`); - setAuditCursorRerunShortcode(row.shortcode); - const body: SocialAccountCommentsAuditCursorRetryRequest = { + setRelayCheckpointRerunShortcode(row.shortcode); + const body: SocialAccountCommentsRelayCheckpointRetryRequest = { limit: 1, shortcodes: [row.shortcode], batch_size: 1, max_comments_per_post: 0, - comments_load_strategy: "cursor_api", + comments_load_strategy: "public_relay", + date_start: searchParams.get("date_start"), + date_end: searchParams.get("date_end"), attach_to_active_run: true, dispatch_immediately: true, force_rerun_existing: true, }; - const trimmedShowFilter = auditCursorShowFilter.trim(); + const trimmedShowFilter = relayCheckpointShowFilter.trim(); if (trimmedShowFilter) { body.show_filter = trimmedShowFilter; } @@ -1070,12 +1193,12 @@ export default function InstagramCommentsPanel({ }, { preferredUser: user }, ); - const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsAuditCursorRetriesResponse & + const data = (await response.json().catch(() => ({}))) as SocialAccountCommentsRelayCheckpointRetriesResponse & ProxyErrorPayload; if (!response.ok || data.ok === false) { throw new Error(readInstagramCommentsErrorMessage(data, "Failed to queue target now")); } - setAuditCursorRecovery(data); + setRelayCheckpointRecovery(data); const runId = readCommentsRunId(data.enqueue?.result) ?? readCommentsRunId(data.active_run) ?? @@ -1086,28 +1209,29 @@ export default function InstagramCommentsPanel({ const createdCount = readNonNegativeInteger(data.enqueue?.result?.created_target_job_count) ?? 0; setScrapeMessage( createdCount > 0 - ? `${row.shortcode} queued now. ${formatInteger(createdCount)} fresh recovery job${createdCount === 1 ? "" : "s"}.` - : `${row.shortcode} recovery requested.`, + ? `${row.shortcode} queued now. ${formatInteger(createdCount)} relay checkpoint job${createdCount === 1 ? "" : "s"}.` + : `${row.shortcode} relay checkpoint requested.`, ); void refreshPosts(); - void refreshAuditCursorRecovery(); + void refreshRelayCheckpointRecovery(); void onSummaryRefresh?.(); } catch (error) { const message = error instanceof Error ? error.message : "Failed to queue target now"; - setAuditCursorRecoveryError(message); + setRelayCheckpointRecoveryError(message); setScrapeError(message); } finally { - setAuditCursorRerunShortcode(null); + setRelayCheckpointRerunShortcode(null); } }, [ - auditCursorShowFilter, + relayCheckpointShowFilter, fetchAdminWithAuth, handle, onSummaryRefresh, platform, - refreshAuditCursorRecovery, + refreshRelayCheckpointRecovery, refreshPosts, + searchParams, user, ], ); @@ -1115,6 +1239,7 @@ export default function InstagramCommentsPanel({ const startCatalogRefresh = useCallback(async () => { if (!user) return; setScrapeError(null); + setIncompleteFillPreview(null); setScrapeMessage("Repairing Instagram auth if needed before queueing refresh..."); setCatalogRefreshPending(true); const body: CatalogBackfillRequest = { @@ -1325,23 +1450,28 @@ export default function InstagramCommentsPanel({ !hasAccess || commentsProgressControlsLocked || cancelRequestedRunId === scrapeRunId; - const commentsActionDisabled = commentsLaunchPending || isScraping || checking || !user || !hasAccess; - const auditCursorRecoveryRows = useMemo( - () => auditCursorRecovery?.progress_rows ?? auditCursorRecovery?.rows ?? [], - [auditCursorRecovery], + const commentsActionDisabled = commentsLaunchPending || commentsPreviewPending || isScraping || checking || !user || !hasAccess; + const commentsPreviewDisabled = commentsPreviewPending || commentsLaunchPending || isScraping || checking || !user || !hasAccess; + const incompleteFillPreviewSummary = useMemo( + () => (incompleteFillPreview ? getIncompleteFillPreviewSummary(incompleteFillPreview) : null), + [incompleteFillPreview], ); - const auditCursorRecoveryTargetCount = - readNonNegativeInteger(auditCursorRecovery?.selected_target_source_ids_count) ?? auditCursorRecoveryRows.length; - const auditCursorRecoveryGap = auditCursorRecoveryRows.reduce( + const relayCheckpointRecoveryRows = useMemo( + () => relayCheckpointRecovery?.progress_rows ?? relayCheckpointRecovery?.rows ?? [], + [relayCheckpointRecovery], + ); + const relayCheckpointRecoveryTargetCount = + readNonNegativeInteger(relayCheckpointRecovery?.selected_target_source_ids_count) ?? relayCheckpointRecoveryRows.length; + const relayCheckpointRecoveryGap = relayCheckpointRecoveryRows.reduce( (total, row) => total + (readNonNegativeInteger(row.missing_comment_gap) ?? 0), 0, ); - const auditCursorRecoveryActiveRunId = readCommentsRunId(auditCursorRecovery?.active_run); - const auditCursorActionDisabled = - auditCursorRecoveryPending || - Boolean(auditCursorRerunShortcode) || - auditCursorRecoveryLoading || - auditCursorRecoveryTargetCount <= 0 || + const relayCheckpointRecoveryActiveRunId = readCommentsRunId(relayCheckpointRecovery?.active_run); + const relayCheckpointActionDisabled = + relayCheckpointRecoveryPending || + Boolean(relayCheckpointRerunShortcode) || + relayCheckpointRecoveryLoading || + relayCheckpointRecoveryTargetCount <= 0 || checking || !user || !hasAccess; @@ -1390,15 +1520,17 @@ export default function InstagramCommentsPanel({ const openPostComments = useCallback( (post: SocialAccountProfilePost, event?: MouseEvent) => { event?.preventDefault(); + manuallyClosedPostSourceIdRef.current = null; setSelectedPost(post); replacePostParam(String(post.source_id || "").trim() || null); }, [replacePostParam], ); const closePostComments = useCallback(() => { + manuallyClosedPostSourceIdRef.current = String(selectedPost?.source_id || selectedPostParam || "").trim() || null; setSelectedPost(null); replacePostParam(null); - }, [replacePostParam]); + }, [replacePostParam, selectedPost?.source_id, selectedPostParam]); const fetchPostCommentsAdmin = useCallback( (input: RequestInfo | URL, init?: RequestInit) => fetchAdminWithAuth(input, init, { preferredUser: user }), @@ -1483,6 +1615,14 @@ export default function InstagramCommentsPanel({ > Incomplete Fill +
+ {supportsInlineCommentsSync ? ( +
+

Streaming Order

+
+

Posts save first, then comments can start streaming as soon as those saved posts exist.

+

Public-first recovery stays default for open gaps, retries, and checkpoint resumptions.

+

Auth-backed comments work is last resort and should only cover leftover blocked posts.

+
+
+ ) : null} + {supportsInlineCommentsSync ? (
-

Cap-free Comments Recovery

+

Public Relay Recovery

- {auditCursorRecoveryLoading - ? "Loading audit cursor targets..." - : `${formatInteger(auditCursorRecoveryTargetCount)} cursor targets / ${formatInteger(auditCursorRecoveryGap)} comments still open`} - {auditCursorRecoveryActiveRunId ? ` / active run ${auditCursorRecoveryActiveRunId.slice(0, 8)}` : ""} + {relayCheckpointRecoveryLoading + ? "Loading relay checkpoint targets..." + : `${formatInteger(relayCheckpointRecoveryTargetCount)} checkpoint targets / ${formatInteger(relayCheckpointRecoveryGap)} comments still open`} + {relayCheckpointRecoveryActiveRunId ? ` / active run ${relayCheckpointRecoveryActiveRunId.slice(0, 8)}` : ""} +

+

+ Public relay uses logged-out Instagram pages. Checkpoint recovery resumes saved relay checkpoints for focused repairs. + Rate limits still apply, so a live run can slow down or split work after preview identifies the targets.

setAuditCursorShowFilter(event.target.value)} + value={relayCheckpointShowFilter} + onChange={(event) => setRelayCheckpointShowFilter(event.target.value)} placeholder="Show filter" className="h-9 min-w-[160px] rounded-lg border border-zinc-200 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 disabled:cursor-not-allowed disabled:opacity-50" - disabled={auditCursorRecoveryPending || Boolean(auditCursorRerunShortcode)} + disabled={relayCheckpointRecoveryPending || Boolean(relayCheckpointRerunShortcode)} />
- {auditCursorRecoveryError ?

{auditCursorRecoveryError}

: null} - {auditCursorRecoveryRows.length > 0 ? ( + {relayCheckpointRecoveryError ?

{relayCheckpointRecoveryError}

: null} + {relayCheckpointRecoveryRows.length > 0 ? (
@@ -1598,17 +1753,17 @@ export default function InstagramCommentsPanel({ - + - {auditCursorRecoveryRows.slice(0, 8).map((row) => { + {relayCheckpointRecoveryRows.slice(0, 8).map((row) => { const activeJobCount = readNonNegativeInteger(row.active_run_job_count) ?? 0; const activeJobTargetCounts = row.active_job_target_counts ?? []; const smallestBatch = activeJobTargetCounts.length > 0 ? Math.min(...activeJobTargetCounts) : null; - const rowRerunPending = auditCursorRerunShortcode === row.shortcode; + const rowRerunPending = relayCheckpointRerunShortcode === row.shortcode; return ( @@ -1632,8 +1787,8 @@ export default function InstagramCommentsPanel({
Post Gap SavedCursorCheckpoint Queue Action
{row.shortcode}
- {auditCursorRecoveryRows.length > 8 ? ( + {relayCheckpointRecoveryRows.length > 8 ? (

- Showing 8 of {formatInteger(auditCursorRecoveryRows.length)} targets. + Showing 8 of {formatInteger(relayCheckpointRecoveryRows.length)} targets.

) : null}
- ) : !auditCursorRecoveryLoading ? ( -

No cursor recovery targets found.

+ ) : !relayCheckpointRecoveryLoading ? ( +

No relay checkpoint recovery targets found.

) : null}
) : null} + {incompleteFillPreviewSummary ? ( +
+
+
+

+ Incomplete Fill Preview +

+

+ Dry-run only. No run was queued and no workers were dispatched. +

+
+
+
+

Targets

+

+ {formatInteger(incompleteFillPreviewSummary.targetCount)} +

+
+
+

Shards

+

+ {formatInteger(incompleteFillPreviewSummary.shardCount)} +

+
+
+

Strategy

+

{incompleteFillPreviewSummary.strategy}

+
+
+

Warnings

+

+ {formatInteger(incompleteFillPreviewSummary.warningCount)} +

+
+
+
+

{incompleteFillPreviewSummary.windowLabel}

+ {incompleteFillPreviewSummary.sampleShortcodes.length > 0 ? ( +

+ Sample: {incompleteFillPreviewSummary.sampleShortcodes.join(", ")} +

+ ) : null} +

+ Public relay does not require auth cookies for comment retrieval, but Instagram can still throttle by IP or session. +

+
+ ) : null} + {scrapeMessage ?

{scrapeMessage}

: null} {progressScopeNotice ?

{progressScopeNotice}

: null} {progressWarning ?

{progressWarning}

: null} diff --git a/apps/web/src/components/admin/season-social-analytics-section.tsx b/apps/web/src/components/admin/season-social-analytics-section.tsx index ce0ac4ba..e188abc8 100644 --- a/apps/web/src/components/admin/season-social-analytics-section.tsx +++ b/apps/web/src/components/admin/season-social-analytics-section.tsx @@ -32,6 +32,7 @@ import { type SocialSyncSessionProgressSnapshot, type SocialSyncSessionStreamPayload, } from "@/lib/admin/social-sync-session"; +import { buildIsoDayRange, SOCIAL_TIME_ZONE } from "@/lib/admin/social-timezone"; import { useSharedPollingResource, useSharedSseResource } from "@/lib/admin/shared-live-resource"; import { logAdminPageReadDiagnostic, measurePayloadBytes } from "@/lib/admin/page-read-diagnostics"; import { @@ -932,7 +933,6 @@ const formatPercent = (value: number): string => `${(value * 100).toFixed(1)}%`; const formatInteger = (value: number | null | undefined): string => INTEGER_FORMATTER.format(Number(value ?? 0)); const formatCompactInteger = (value: number | null | undefined): string => COMPACT_INTEGER_FORMATTER.format(Math.max(0, Number(value ?? 0))); -const SOCIAL_TIME_ZONE = "America/New_York"; const DATE_TOKEN_RE = /^(\d{4})-(\d{2})-(\d{2})$/; const parseDateToken = ( @@ -947,67 +947,6 @@ const parseDateToken = ( }; }; -const getTimeZoneOffsetMs = (timestampMs: number, timeZone: string): number => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - }).formatToParts(new Date(timestampMs)); - const values: Record = {}; - for (const part of parts) { - if (part.type === "literal") continue; - values[part.type] = Number(part.value); - } - const zonedAsUtc = Date.UTC( - values.year ?? 0, - (values.month ?? 1) - 1, - values.day ?? 1, - values.hour ?? 0, - values.minute ?? 0, - values.second ?? 0, - ); - return zonedAsUtc - timestampMs; -}; - -const toZonedUtcIso = ( - parts: { year: number; month: number; day: number }, - time: { hour: number; minute: number; second: number; millisecond?: number }, -): string => { - const baseUtc = Date.UTC( - parts.year, - parts.month - 1, - parts.day, - time.hour, - time.minute, - time.second, - time.millisecond ?? 0, - ); - const firstOffset = getTimeZoneOffsetMs(baseUtc, SOCIAL_TIME_ZONE); - let correctedUtc = baseUtc - firstOffset; - const secondOffset = getTimeZoneOffsetMs(correctedUtc, SOCIAL_TIME_ZONE); - if (secondOffset !== firstOffset) { - correctedUtc = baseUtc - secondOffset; - } - return new Date(correctedUtc).toISOString(); -}; - -const addDays = ( - parts: { year: number; month: number; day: number }, - days: number, -): { year: number; month: number; day: number } => { - const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days, 12, 0, 0)); - return { - year: date.getUTCFullYear(), - month: date.getUTCMonth() + 1, - day: date.getUTCDate(), - }; -}; - const DATE_TIME_DISPLAY_OPTIONS: Intl.DateTimeFormatOptions = { year: "numeric", month: "numeric", @@ -1132,18 +1071,6 @@ const formatDateRangeLabel = (start: string, end: string): string => { )}`; }; -const buildIsoDayRange = (dayLocal: string): { dateStart: string; dateEnd: string } | null => { - const parsed = parseDateToken(dayLocal); - if (!parsed) return null; - const dateStart = toZonedUtcIso(parsed, { hour: 0, minute: 0, second: 0, millisecond: 0 }); - const nextDay = addDays(parsed, 1); - const nextDateStart = toZonedUtcIso(nextDay, { hour: 0, minute: 0, second: 0, millisecond: 0 }); - return { - dateStart, - dateEnd: new Date(Date.parse(nextDateStart) - 1).toISOString(), - }; -}; - type CoverageSummary = { postsPct: number | null; postsPctLabel: string | null; diff --git a/apps/web/src/components/admin/social-growth-section.tsx b/apps/web/src/components/admin/social-growth-section.tsx index 2ec6711f..43a5bbbf 100644 --- a/apps/web/src/components/admin/social-growth-section.tsx +++ b/apps/web/src/components/admin/social-growth-section.tsx @@ -55,6 +55,8 @@ type InstagramFollowingResponse = { page_size?: number; total?: number; total_pages?: number; + has_more?: boolean; + next_page?: number | null; }; }; @@ -154,6 +156,16 @@ function formatRelativeAge(ageHours: number | null | undefined): string { return `${Math.round(ageHours / 24)}d ago`; } +function sortSocialBladeChartPoints( + points: Array<{ date: string; followers: number }>, +): Array<{ date: string; followers: number }> { + return [...points].sort((a, b) => a.date.localeCompare(b.date)); +} + +function sortSocialBladeMetricRows(rows: Array>): Array> { + return [...rows].sort((a, b) => String(a.Date ?? "").localeCompare(String(b.Date ?? ""))); +} + function getFreshnessChip(data: SocialBladeGrowthData | null): { label: string; className: string } { if (!data) { return { @@ -630,7 +642,6 @@ export default function SocialGrowthSection({ const followingPagination = followingData?.pagination ?? null; const followingCurrentPage = readPositiveInteger(followingPagination?.page, followingData ? 1 : 0); - const followingPageSize = readPositiveInteger(followingPagination?.page_size, FOLLOWING_PAGE_SIZE); const followingTotalPages = typeof followingPagination?.total_pages === "number" && followingPagination.total_pages > 0 ? Math.floor(followingPagination.total_pages) @@ -639,20 +650,25 @@ export default function SocialGrowthSection({ typeof followingPagination?.total === "number" && followingPagination.total >= 0 ? Math.floor(followingPagination.total) : null; + const followingExplicitHasMore = + typeof followingPagination?.has_more === "boolean" ? followingPagination.has_more : null; + const followingNextPage = + typeof followingPagination?.next_page === "number" && followingPagination.next_page > followingCurrentPage + ? Math.floor(followingPagination.next_page) + : null; const followingLoadedCount = followingData?.items?.length ?? 0; - const followingHasNextPage = Boolean( - followingData && - (followingTotalPages !== null - ? followingCurrentPage < followingTotalPages - : followingTotal !== null - ? followingLoadedCount < followingTotal - : followingLoadedCount >= followingPageSize), - ); + const followingHasNextPage = Boolean(followingData) && (() => { + if (followingExplicitHasMore !== null) return followingExplicitHasMore; + if (followingNextPage !== null) return true; + if (followingTotalPages !== null) return followingCurrentPage < followingTotalPages; + if (followingTotal !== null) return followingLoadedCount < followingTotal; + return false; + })(); const loadNextFollowingPage = useCallback(() => { if (!followingHasNextPage || followingLoading) return; - void loadFollowingPage(Math.max(1, followingCurrentPage) + 1); - }, [followingCurrentPage, followingHasNextPage, followingLoading, loadFollowingPage]); + void loadFollowingPage(followingNextPage ?? Math.max(1, followingCurrentPage) + 1); + }, [followingCurrentPage, followingHasNextPage, followingLoading, followingNextPage, loadFollowingPage]); useEffect(() => { setFollowingData(null); @@ -751,7 +767,8 @@ export default function SocialGrowthSection({ const { profile_stats: stats, rankings, daily_total_followers_chart: chart, daily_channel_metrics_60day: metrics } = data; const freshnessChip = getFreshnessChip(data); const effectiveAvatarUrl = avatarUrl || resolveSocialBladeAvatarUrl(data); - const chartPoints = chart?.data ?? []; + const chartPoints = sortSocialBladeChartPoints(chart?.data ?? []); + const metricRows = sortSocialBladeMetricRows(metrics?.data ?? []); const statsCards = [ { key: "followers", @@ -1100,7 +1117,7 @@ export default function SocialGrowthSection({ - {metrics?.data.length ? ( + {metricRows.length ? (

Daily Channel Metrics

@@ -1123,7 +1140,7 @@ export default function SocialGrowthSection({ - {metrics.data.map((row, i) => ( + {metricRows.map((row, i) => ( {metrics.headers.map((header) => ( diff --git a/apps/web/src/components/admin/social-week/WeekDetailPageView.tsx b/apps/web/src/components/admin/social-week/WeekDetailPageView.tsx index 16c52313..4a17e5f2 100644 --- a/apps/web/src/components/admin/social-week/WeekDetailPageView.tsx +++ b/apps/web/src/components/admin/social-week/WeekDetailPageView.tsx @@ -46,6 +46,7 @@ import { type SocialSyncRetryKind, type SocialSyncSessionStreamPayload, } from "@/lib/admin/social-sync-session"; +import { buildIsoDayRange, SOCIAL_TIME_ZONE } from "@/lib/admin/social-timezone"; import { invalidateAdminSnapshotFamilies } from "@/lib/admin/admin-snapshot-client"; import { useSharedPollingResource, useSharedSseResource } from "@/lib/admin/shared-live-resource"; import type { PhotoMetadata } from "@/lib/photo-metadata"; @@ -994,7 +995,6 @@ const UUID_RE = const looksLikeUuid = (value: string): boolean => UUID_RE.test(value); const ACTIVE_RUN_STATUSES = new Set(["queued", "pending", "retrying", "running", "cancelling"]); const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]); -const SOCIAL_TIME_ZONE = "America/New_York"; const DATE_TOKEN_RE = /^\d{4}-\d{2}-\d{2}$/; const COMMENT_SYNC_MAX_PASSES = 1; const COMMENT_SYNC_MAX_DURATION_MS = 90 * 60 * 1000; @@ -1235,89 +1235,6 @@ const toLocalDateToken = (iso: string | null | undefined): string | null => { return `${values.year}-${values.month}-${values.day}`; }; -const parseDateToken = (value: string): { year: number; month: number; day: number } | null => { - const match = DATE_TOKEN_RE.exec(value); - if (!match) return null; - return { - year: Number(match[0].slice(0, 4)), - month: Number(match[0].slice(5, 7)), - day: Number(match[0].slice(8, 10)), - }; -}; - -const getTimeZoneOffsetMs = (timestampMs: number, timeZone: string): number => { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: "h23", - }).formatToParts(new Date(timestampMs)); - const values: Record = {}; - for (const part of parts) { - if (part.type === "literal") continue; - values[part.type] = Number(part.value); - } - const zonedAsUtc = Date.UTC( - values.year ?? 0, - (values.month ?? 1) - 1, - values.day ?? 1, - values.hour ?? 0, - values.minute ?? 0, - values.second ?? 0, - ); - return zonedAsUtc - timestampMs; -}; - -const toZonedUtcIso = ( - parts: { year: number; month: number; day: number }, - time: { hour: number; minute: number; second: number; millisecond?: number }, -): string => { - const baseUtc = Date.UTC( - parts.year, - parts.month - 1, - parts.day, - time.hour, - time.minute, - time.second, - time.millisecond ?? 0, - ); - const firstOffset = getTimeZoneOffsetMs(baseUtc, SOCIAL_TIME_ZONE); - let correctedUtc = baseUtc - firstOffset; - const secondOffset = getTimeZoneOffsetMs(correctedUtc, SOCIAL_TIME_ZONE); - if (secondOffset !== firstOffset) { - correctedUtc = baseUtc - secondOffset; - } - return new Date(correctedUtc).toISOString(); -}; - -const addDays = ( - parts: { year: number; month: number; day: number }, - days: number, -): { year: number; month: number; day: number } => { - const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days, 12, 0, 0)); - return { - year: date.getUTCFullYear(), - month: date.getUTCMonth() + 1, - day: date.getUTCDate(), - }; -}; - -const buildIsoDayRange = (dayLocal: string): { dateStart: string; dateEnd: string } | null => { - const parsed = parseDateToken(dayLocal); - if (!parsed) return null; - const dateStart = toZonedUtcIso(parsed, { hour: 0, minute: 0, second: 0, millisecond: 0 }); - const nextDay = addDays(parsed, 1); - const nextDateStart = toZonedUtcIso(nextDay, { hour: 0, minute: 0, second: 0, millisecond: 0 }); - return { - dateStart, - dateEnd: new Date(Date.parse(nextDateStart) - 1).toISOString(), - }; -}; - const toDayFilterLabel = (token: string | null): string | null => { if (!token || !DATE_TOKEN_RE.test(token)) return null; const [yearRaw, monthRaw, dayRaw] = token.split("-"); @@ -6269,12 +6186,15 @@ export default function WeekDetailPage() { } satisfies WorkerHealthPayload; }, [getSyncRunRequestHeaders]); + const shouldPollLiveWeekSnapshot = + hasValidNumericPathParams && isAdmin && (syncingComments ? !syncSessionStreamConnected : Boolean(data)); + const liveWeekSnapshot = useSharedPollingResource<{ payload: WeekSocialSnapshot; cacheStatus: string; }>({ key: `week-social-snapshot:${showIdForApi}:${seasonNumber}:${weekIndex}:${sourceScope}:${resolvedSeasonId ?? "none"}:${platformFilter}:${syncRunId ?? "none"}:${syncSessionId ?? "none"}`, - shouldRun: hasValidNumericPathParams && isAdmin && (syncingComments || Boolean(data)), + shouldRun: shouldPollLiveWeekSnapshot, intervalMs: syncingComments ? SYNC_ACTIVE_POLL_INTERVAL_MS : 30_000, fetchData: async (signal, request) => await fetchWeekSnapshot({ diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx new file mode 100644 index 00000000..1fe3176d --- /dev/null +++ b/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 00000000..cacff11d --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/apps/web/src/components/ui/card.tsx b/apps/web/src/components/ui/card.tsx new file mode 100644 index 00000000..4458dae1 --- /dev/null +++ b/apps/web/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 00000000..d763cd9a --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/apps/web/src/components/ui/separator.tsx b/apps/web/src/components/ui/separator.tsx new file mode 100644 index 00000000..d4570908 --- /dev/null +++ b/apps/web/src/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { Separator as SeparatorPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/apps/web/src/lib/admin/admin-url-defaults.ts b/apps/web/src/lib/admin/admin-url-defaults.ts index f228623d..932a0eea 100644 --- a/apps/web/src/lib/admin/admin-url-defaults.ts +++ b/apps/web/src/lib/admin/admin-url-defaults.ts @@ -1,10 +1,52 @@ export const CLASSIC_LOCAL_ADMIN_ORIGIN = "http://admin.localhost:3000"; export const PORTLESS_APP_ORIGIN = "https://trr.localhost"; export const PORTLESS_ADMIN_ORIGIN = "https://admin.trr.localhost"; -export const PORTLESS_ADMIN_DASHBOARD_URL = `${PORTLESS_ADMIN_ORIGIN}/admin`; +export const PORTLESS_ADMIN_DASHBOARD_URL = PORTLESS_ADMIN_ORIGIN; export const PORTLESS_API_ORIGIN = "https://api.trr.localhost"; +export const PORTLESS_API_HEALTH_URL = `${PORTLESS_API_ORIGIN}/health/live`; +export const PORTLESS_WORDLE_ORIGIN = "https://wordle.trr.localhost"; export const LEGACY_LOCAL_ADMIN_FALLBACK_ENV = "TRR_LEGACY_LOCAL_ADMIN_FALLBACK"; +export const PORTLESS_ROUTE_DEFINITIONS = { + app: { + id: "trr", + label: "App", + url: PORTLESS_APP_ORIGIN, + expectedPath: "/", + }, + admin: { + id: "admin.trr", + label: "Admin", + url: PORTLESS_ADMIN_DASHBOARD_URL, + expectedPath: "/", + }, + api: { + id: "api.trr", + label: "API", + url: PORTLESS_API_HEALTH_URL, + expectedPath: "/health/live", + }, + wordle: { + id: "wordle.trr", + label: "Wordle", + url: PORTLESS_WORDLE_ORIGIN, + expectedPath: "/", + }, +} as const; + +export const PORTLESS_ROUTE_LINKS = [ + PORTLESS_ROUTE_DEFINITIONS.admin, + PORTLESS_ROUTE_DEFINITIONS.app, + PORTLESS_ROUTE_DEFINITIONS.api, +] as const; + +export const PORTLESS_STATUS_ROUTES = [ + PORTLESS_ROUTE_DEFINITIONS.app, + PORTLESS_ROUTE_DEFINITIONS.admin, + PORTLESS_ROUTE_DEFINITIONS.api, + PORTLESS_ROUTE_DEFINITIONS.wordle, +] as const; + const LOOPBACK_ADMIN_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); type AdminUrlEnv = Record; diff --git a/apps/web/src/lib/admin/api-references/generated/inventory.ts b/apps/web/src/lib/admin/api-references/generated/inventory.ts index d91697ac..ac78b83b 100644 --- a/apps/web/src/lib/admin/api-references/generated/inventory.ts +++ b/apps/web/src/lib/admin/api-references/generated/inventory.ts @@ -3,9 +3,9 @@ import type { AdminApiReferenceInventory } from "@/lib/admin/api-references/type export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "inventorySchemaVersion": "1.0.0", "generatorVersion": "1.0.0", - "generatedAt": "2026-06-18T22:08:52.953Z", - "sourceCommitSha": "e0a97252b93da0ce93bfef851e4901682ad8d113", - "overrideDigest": "1a9e22b4c9b465cc3dab3ee0fcfc469f25b643ecae9e10baf11cdd12f9133e71", + "generatedAt": "2026-07-05T23:35:29.612Z", + "sourceCommitSha": "b4198ec471d55204af3f5bbeca194ac0a9706ab7", + "overrideDigest": "cd85a76fd2fc977cbc691ccab80ce844f9ea783e118af35eb3d9766e5d61fd36", "nodes": [ { "id": "backend:DELETE:/api/v1/admin/brands/logos/options/saved/[assetId]", @@ -817,6 +817,106 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "low" }, + { + "id": "backend:GET:/api/v1/admin/people/socialblade/calls/[safeCallId]", + "kind": "backend_endpoint", + "title": "GET /api/v1/admin/people/socialblade/calls/[safeCallId]", + "pathPattern": "/api/v1/admin/people/socialblade/calls/[safeCallId]", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social-growth/calls/[callId]/route.ts", + "sourceLocator": { + "line": 32, + "matchedText": "`/admin/people/socialblade/calls/${encodeURIComponent(safeCallId)}`" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:getBackendApiUrl" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "indirect", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, + { + "id": "backend:GET:/api/v1/admin/people/socialblade/cookies/health", + "kind": "backend_endpoint", + "title": "GET /api/v1/admin/people/socialblade/cookies/health", + "pathPattern": "/api/v1/admin/people/socialblade/cookies/health", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social-growth/cookies/health/route.ts", + "sourceLocator": { + "line": 23, + "matchedText": "`/admin/people/socialblade/cookies/health${query ? `?${query}` : \"\"}`" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:getBackendApiUrl" + ], + "usageTier": "high", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "indirect", + "viewKinds": [ + "list" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, + { + "id": "backend:GET:/api/v1/admin/people/socialblade/history", + "kind": "backend_endpoint", + "title": "GET /api/v1/admin/people/socialblade/history", + "pathPattern": "/api/v1/admin/people/socialblade/history", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social-growth/history/route.ts", + "sourceLocator": { + "line": 23, + "matchedText": "`/admin/people/socialblade/history${query ? `?${query}` : \"\"}`" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:getBackendApiUrl" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "indirect", + "viewKinds": [ + "list" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "backend:GET:/api/v1/admin/person/[personId]/fandom", "kind": "backend_endpoint", @@ -4655,6 +4755,74 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "high" }, + { + "id": "page:/admin/dev-dashboard/instagram-catalog-backfill-mockup", + "kind": "ui_surface", + "title": "Admin / Dev Dashboard / Instagram Catalog Backfill Mockup", + "pathPattern": "/admin/dev-dashboard/instagram-catalog-backfill-mockup", + "symbol": "page", + "sourceFile": "src/app/admin/dev-dashboard/instagram-catalog-backfill-mockup/page.tsx", + "sourceLocator": { + "line": 1, + "symbol": "page" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:admin_page", + "derived:static_only_page" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": true, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list" + ], + "staticOnly": true, + "payloadRisk": "high", + "fanoutRisk": "low" + }, + { + "id": "page:/admin/dev-dashboard/instagram-comments-mockup", + "kind": "ui_surface", + "title": "Admin / Dev Dashboard / Instagram Comments Mockup", + "pathPattern": "/admin/dev-dashboard/instagram-comments-mockup", + "symbol": "page", + "sourceFile": "src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx", + "sourceLocator": { + "line": 1, + "symbol": "page" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:admin_page" + ], + "usageTier": "continuous", + "polls": true, + "pollCadenceMs": null, + "automatic": true, + "loadsLargeDatasets": true, + "usesPagination": true, + "returnsWideRowsOrBlobsOrRawJson": true, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "high", + "fanoutRisk": "high" + }, { "id": "page:/admin/dev-dashboard/skills-and-agents", "kind": "ui_surface", @@ -5148,8 +5316,8 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "polls": false, "pollCadenceMs": null, "automatic": false, - "loadsLargeDatasets": false, - "usesPagination": false, + "loadsLargeDatasets": true, + "usesPagination": true, "returnsWideRowsOrBlobsOrRawJson": true, "fansOutQueries": false, "postgresAccess": "none", @@ -5439,146 +5607,6 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "low" }, - { - "id": "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]", - "kind": "ui_surface", - "title": "Admin / Social / Reddit / CommunitySlug / ShowSlug / S[seasonNumber]", - "pathPattern": "/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]", - "symbol": "page", - "sourceFile": "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/page.tsx", - "sourceLocator": { - "line": 1, - "symbol": "page" - }, - "provenance": "static_scan", - "confidence": "high", - "verificationStatus": "verified", - "basis": [ - "static_scan:admin_page", - "derived:static_only_page" - ], - "usageTier": "manual", - "polls": false, - "pollCadenceMs": null, - "automatic": false, - "loadsLargeDatasets": true, - "usesPagination": false, - "returnsWideRowsOrBlobsOrRawJson": false, - "fansOutQueries": false, - "postgresAccess": "none", - "viewKinds": [ - "list", - "detail" - ], - "staticOnly": true, - "payloadRisk": "high", - "fanoutRisk": "low" - }, - { - "id": "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]", - "kind": "ui_surface", - "title": "Admin / Social / Reddit / CommunitySlug / ShowSlug / S[seasonNumber] / WindowKey", - "pathPattern": "/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]", - "symbol": "page", - "sourceFile": "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/page.tsx", - "sourceLocator": { - "line": 1, - "symbol": "page" - }, - "provenance": "static_scan", - "confidence": "high", - "verificationStatus": "verified", - "basis": [ - "static_scan:admin_page", - "derived:static_only_page" - ], - "usageTier": "manual", - "polls": false, - "pollCadenceMs": null, - "automatic": false, - "loadsLargeDatasets": true, - "usesPagination": false, - "returnsWideRowsOrBlobsOrRawJson": false, - "fansOutQueries": false, - "postgresAccess": "none", - "viewKinds": [ - "list", - "detail" - ], - "staticOnly": true, - "payloadRisk": "high", - "fanoutRisk": "low" - }, - { - "id": "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]", - "kind": "ui_surface", - "title": "Admin / Social / Reddit / CommunitySlug / ShowSlug / S[seasonNumber] / WindowKey / DetailSlug", - "pathPattern": "/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]", - "symbol": "page", - "sourceFile": "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]/page.tsx", - "sourceLocator": { - "line": 1, - "symbol": "page" - }, - "provenance": "static_scan", - "confidence": "high", - "verificationStatus": "verified", - "basis": [ - "static_scan:admin_page", - "derived:static_only_page" - ], - "usageTier": "manual", - "polls": false, - "pollCadenceMs": null, - "automatic": false, - "loadsLargeDatasets": true, - "usesPagination": false, - "returnsWideRowsOrBlobsOrRawJson": false, - "fansOutQueries": false, - "postgresAccess": "none", - "viewKinds": [ - "list", - "detail" - ], - "staticOnly": true, - "payloadRisk": "high", - "fanoutRisk": "low" - }, - { - "id": "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]", - "kind": "ui_surface", - "title": "Admin / Social / Reddit / CommunitySlug / ShowSlug / S[seasonNumber] / WindowKey / Post / PostId", - "pathPattern": "/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]", - "symbol": "page", - "sourceFile": "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]/page.tsx", - "sourceLocator": { - "line": 1, - "symbol": "page" - }, - "provenance": "static_scan", - "confidence": "high", - "verificationStatus": "verified", - "basis": [ - "static_scan:admin_page", - "derived:static_only_page" - ], - "usageTier": "manual", - "polls": false, - "pollCadenceMs": null, - "automatic": false, - "loadsLargeDatasets": true, - "usesPagination": false, - "returnsWideRowsOrBlobsOrRawJson": false, - "fansOutQueries": false, - "postgresAccess": "none", - "viewKinds": [ - "list", - "detail" - ], - "staticOnly": true, - "payloadRisk": "high", - "fanoutRisk": "low" - }, { "id": "page:/admin/survey-responses", "kind": "ui_surface", @@ -6033,6 +6061,37 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "low", "fanoutRisk": "low" }, + { + "id": "poll:src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx::set-interval-1", + "kind": "polling_loop", + "title": "Admin / Dev Dashboard / Instagram Comments Mockup set-interval", + "pathPattern": "/admin/dev-dashboard/instagram-comments-mockup", + "symbol": "set-interval", + "sourceFile": "src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx", + "sourceLocator": { + "line": 253, + "matchedText": "setInterval" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:setInterval" + ], + "usageTier": "continuous", + "polls": true, + "pollCadenceMs": null, + "automatic": true, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "indirect", + "viewKinds": [], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "high" + }, { "id": "poll:src/app/admin/trr-shows/people/[personId]/PersonPageClient.tsx::set-interval-1", "kind": "polling_loop", @@ -6072,7 +6131,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "symbol": "set-interval", "sourceFile": "src/components/admin/social-week/WeekDetailPageView.tsx", "sourceLocator": { - "line": 7127, + "line": 7047, "matchedText": "setInterval" }, "provenance": "static_scan", @@ -6103,7 +6162,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "symbol": "set-interval", "sourceFile": "src/components/admin/social-week/WeekDetailPageView.tsx", "sourceLocator": { - "line": 7135, + "line": 7055, "matchedText": "setInterval" }, "provenance": "static_scan", @@ -9011,6 +9070,37 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "low" }, + { + "id": "route:GET:/api/admin/social/media-queue/snapshots", + "kind": "api_route", + "title": "GET /api/admin/social/media-queue/snapshots", + "pathPattern": "/api/admin/social/media-queue/snapshots", + "symbol": "GET", + "sourceFile": "src/app/api/admin/social/media-queue/snapshots/route.ts", + "sourceLocator": { + "line": 43, + "symbol": "GET" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:GET:/api/admin/surveys", "kind": "api_route", @@ -12239,6 +12329,40 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "low" }, + { + "id": "route:GET:/api/admin/trr-api/social-growth/calls/[callId]", + "kind": "api_route", + "title": "GET /api/admin/trr-api/social-growth/calls/[callId]", + "pathPattern": "/api/admin/trr-api/social-growth/calls/[callId]", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social-growth/calls/[callId]/route.ts", + "sourceLocator": { + "line": 22, + "symbol": "GET" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:GET:/api/admin/trr-api/social-growth/cast-comparison/snapshot", "kind": "api_route", @@ -12273,6 +12397,72 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "high" }, + { + "id": "route:GET:/api/admin/trr-api/social-growth/cookies/health", + "kind": "api_route", + "title": "GET /api/admin/trr-api/social-growth/cookies/health", + "pathPattern": "/api/admin/trr-api/social-growth/cookies/health", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social-growth/cookies/health/route.ts", + "sourceLocator": { + "line": 18, + "symbol": "GET" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "high", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, + { + "id": "route:GET:/api/admin/trr-api/social-growth/history", + "kind": "api_route", + "title": "GET /api/admin/trr-api/social-growth/history", + "pathPattern": "/api/admin/trr-api/social-growth/history", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social-growth/history/route.ts", + "sourceLocator": { + "line": 18, + "symbol": "GET" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:GET:/api/admin/trr-api/social/ingest/health-dot", "kind": "api_route", @@ -12521,7 +12711,41 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "automatic": false, "loadsLargeDatasets": true, "usesPagination": false, - "returnsWideRowsOrBlobsOrRawJson": true, + "returnsWideRowsOrBlobsOrRawJson": true, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "high", + "fanoutRisk": "low" + }, + { + "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", + "kind": "api_route", + "title": "GET /api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue/route.ts", + "sourceLocator": { + "line": 8, + "symbol": "GET" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": true, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, "fansOutQueries": false, "postgresAccess": "none", "viewKinds": [ @@ -12533,14 +12757,14 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "fanoutRisk": "low" }, { - "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", + "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", "kind": "api_route", - "title": "GET /api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", - "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", + "title": "GET /api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", "symbol": "GET", - "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue/route.ts", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress/route.ts", "sourceLocator": { - "line": 8, + "line": 68, "symbol": "GET" }, "provenance": "static_scan", @@ -12555,7 +12779,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "automatic": false, "loadsLargeDatasets": true, "usesPagination": false, - "returnsWideRowsOrBlobsOrRawJson": false, + "returnsWideRowsOrBlobsOrRawJson": true, "fansOutQueries": false, "postgresAccess": "none", "viewKinds": [ @@ -12567,14 +12791,14 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "fanoutRisk": "low" }, { - "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", + "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent", "kind": "api_route", - "title": "GET /api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", - "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", + "title": "GET /api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent", "symbol": "GET", - "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress/route.ts", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent/route.ts", "sourceLocator": { - "line": 69, + "line": 305, "symbol": "GET" }, "provenance": "static_scan", @@ -12588,7 +12812,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "pollCadenceMs": null, "automatic": false, "loadsLargeDatasets": true, - "usesPagination": false, + "usesPagination": true, "returnsWideRowsOrBlobsOrRawJson": true, "fansOutQueries": false, "postgresAccess": "none", @@ -12768,6 +12992,40 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "low" }, + { + "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary", + "kind": "api_route", + "title": "GET /api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary", + "symbol": "GET", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary/route.ts", + "sourceLocator": { + "line": 84, + "symbol": "GET" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "high", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/cookies/health", "kind": "api_route", @@ -12876,7 +13134,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "symbol": "GET", "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/live-profile-total/route.ts", "sourceLocator": { - "line": 25, + "line": 38, "symbol": "GET" }, "provenance": "static_scan", @@ -12977,7 +13235,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "symbol": "GET", "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/snapshot/route.ts", "sourceLocator": { - "line": 136, + "line": 116, "symbol": "GET" }, "provenance": "static_scan", @@ -17517,6 +17775,72 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "low", "fanoutRisk": "low" }, + { + "id": "route:POST:/api/admin/trr-api/social/ingest/media-mirror/drain-account", + "kind": "api_route", + "title": "POST /api/admin/trr-api/social/ingest/media-mirror/drain-account", + "pathPattern": "/api/admin/trr-api/social/ingest/media-mirror/drain-account", + "symbol": "POST", + "sourceFile": "src/app/api/admin/trr-api/social/ingest/media-mirror/drain-account/route.ts", + "sourceLocator": { + "line": 20, + "symbol": "POST" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": true, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, + { + "id": "route:POST:/api/admin/trr-api/social/ingest/media-mirror/recover-stale", + "kind": "api_route", + "title": "POST /api/admin/trr-api/social/ingest/media-mirror/recover-stale", + "pathPattern": "/api/admin/trr-api/social/ingest/media-mirror/recover-stale", + "symbol": "POST", + "sourceFile": "src/app/api/admin/trr-api/social/ingest/media-mirror/recover-stale/route.ts", + "sourceLocator": { + "line": 15, + "symbol": "POST" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": true, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:POST:/api/admin/trr-api/social/ingest/recent-failures/dismiss", "kind": "api_route", @@ -18091,6 +18415,40 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "high", "fanoutRisk": "low" }, + { + "id": "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup", + "kind": "api_route", + "title": "POST /api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup", + "symbol": "POST", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup/route.ts", + "sourceLocator": { + "line": 14, + "symbol": "POST" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/cancel", "kind": "api_route", @@ -18193,6 +18551,74 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "payloadRisk": "low", "fanoutRisk": "low" }, + { + "id": "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery", + "kind": "api_route", + "title": "POST /api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery", + "symbol": "POST", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery/route.ts", + "sourceLocator": { + "line": 14, + "symbol": "POST" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, + { + "id": "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth", + "kind": "api_route", + "title": "POST /api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth", + "pathPattern": "/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth", + "symbol": "POST", + "sourceFile": "src/app/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth/route.ts", + "sourceLocator": { + "line": 14, + "symbol": "POST" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:app_api_route" + ], + "usageTier": "manual", + "polls": false, + "pollCadenceMs": null, + "automatic": false, + "loadsLargeDatasets": false, + "usesPagination": false, + "returnsWideRowsOrBlobsOrRawJson": false, + "fansOutQueries": false, + "postgresAccess": "none", + "viewKinds": [ + "list", + "detail" + ], + "staticOnly": false, + "payloadRisk": "low", + "fanoutRisk": "low" + }, { "id": "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/resume", "kind": "api_route", @@ -19370,7 +19796,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/components/admin/social-week/WeekDetailPageView.tsx", "sourceLocator": { - "line": 7127, + "line": 7047, "matchedText": "setInterval" }, "provenance": "static_scan", @@ -19388,7 +19814,25 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/components/admin/social-week/WeekDetailPageView.tsx", "sourceLocator": { - "line": 7135, + "line": 7055, + "matchedText": "setInterval" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:setInterval" + ] + }, + { + "id": "contains_polling:page:/admin/dev-dashboard/instagram-comments-mockup:poll:src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx::set-interval-1", + "kind": "contains_polling", + "from": "page:/admin/dev-dashboard/instagram-comments-mockup", + "to": "poll:src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx::set-interval-1", + "title": null, + "sourceFile": "src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx", + "sourceLocator": { + "line": 253, "matchedText": "setInterval" }, "provenance": "static_scan", @@ -19838,7 +20282,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/components/admin/design-system/BrandFontMatchesPanel.tsx", "sourceLocator": { - "line": 343, + "line": 285, "matchedText": "\"/api/admin/design-system/brand-font-matches?refresh=1\"" }, "provenance": "static_scan", @@ -20036,7 +20480,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/components/admin/social-week/WeekDetailPageView.tsx", "sourceLocator": { - "line": 5055, + "line": 4972, "matchedText": "`/api/admin/trr-api/shows/resolve-slug?slug=${encodeURIComponent(raw)}`" }, "provenance": "static_scan", @@ -20180,7 +20624,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/page.tsx", "sourceLocator": { - "line": 232, + "line": 238, "matchedText": "`/api/admin/trr-api/shows/${showId}/refresh/stream`" }, "provenance": "static_scan", @@ -20198,7 +20642,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 10530, + "line": 10536, "matchedText": "`/api/admin/trr-api/shows/settings/show-core-auto-refresh`" }, "provenance": "static_scan", @@ -20216,7 +20660,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 3703, + "line": 3704, "matchedText": "`/api/admin/trr-api/people/${personId}/refresh-images`" }, "provenance": "static_scan", @@ -20234,7 +20678,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 3617, + "line": 3618, "matchedText": "`/api/admin/trr-api/people/${personId}/refresh-images/stream`" }, "provenance": "static_scan", @@ -20252,7 +20696,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 3410, + "line": 3411, "matchedText": "`/api/admin/trr-api/people/${personId}/refresh-profile/stream`" }, "provenance": "static_scan", @@ -20270,7 +20714,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 3787, + "line": 3788, "matchedText": "`/api/admin/trr-api/people/${personId}/reprocess-images/stream`" }, "provenance": "static_scan", @@ -20288,7 +20732,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 9675, + "line": 9676, "matchedText": "`/api/admin/trr-api/shows/${showId}/assets/batch-jobs/stream`" }, "provenance": "static_scan", @@ -20306,7 +20750,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 6637, + "line": 6638, "matchedText": "`/api/admin/trr-api/shows/${showId}/import-bravo/preview/stream`" }, "provenance": "static_scan", @@ -20324,7 +20768,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 10771, + "line": 10745, "matchedText": "`/api/admin/trr-api/shows/${showId}/refresh/stream`" }, "provenance": "static_scan", @@ -20342,7 +20786,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "title": null, "sourceFile": "src/app/admin/trr-shows/[showId]/page.tsx", "sourceLocator": { - "line": 10562, + "line": 10572, "matchedText": "`/api/admin/trr-api/shows/settings/show-core-auto-refresh`" }, "provenance": "static_scan", @@ -21414,6 +21858,60 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "static_scan:getBackendApiUrl" ] }, + { + "id": "proxies_to:route:GET:/api/admin/trr-api/social-growth/calls/[callId]:backend:GET:/api/v1/admin/people/socialblade/calls/[safeCallId]", + "kind": "proxies_to", + "from": "route:GET:/api/admin/trr-api/social-growth/calls/[callId]", + "to": "backend:GET:/api/v1/admin/people/socialblade/calls/[safeCallId]", + "title": null, + "sourceFile": "src/app/api/admin/trr-api/social-growth/calls/[callId]/route.ts", + "sourceLocator": { + "line": 32, + "matchedText": "`/admin/people/socialblade/calls/${encodeURIComponent(safeCallId)}`" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:getBackendApiUrl" + ] + }, + { + "id": "proxies_to:route:GET:/api/admin/trr-api/social-growth/cookies/health:backend:GET:/api/v1/admin/people/socialblade/cookies/health", + "kind": "proxies_to", + "from": "route:GET:/api/admin/trr-api/social-growth/cookies/health", + "to": "backend:GET:/api/v1/admin/people/socialblade/cookies/health", + "title": null, + "sourceFile": "src/app/api/admin/trr-api/social-growth/cookies/health/route.ts", + "sourceLocator": { + "line": 23, + "matchedText": "`/admin/people/socialblade/cookies/health${query ? `?${query}` : \"\"}`" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:getBackendApiUrl" + ] + }, + { + "id": "proxies_to:route:GET:/api/admin/trr-api/social-growth/history:backend:GET:/api/v1/admin/people/socialblade/history", + "kind": "proxies_to", + "from": "route:GET:/api/admin/trr-api/social-growth/history", + "to": "backend:GET:/api/v1/admin/people/socialblade/history", + "title": null, + "sourceFile": "src/app/api/admin/trr-api/social-growth/history/route.ts", + "sourceLocator": { + "line": 23, + "matchedText": "`/admin/people/socialblade/history${query ? `?${query}` : \"\"}`" + }, + "provenance": "static_scan", + "confidence": "high", + "verificationStatus": "verified", + "basis": [ + "static_scan:getBackendApiUrl" + ] + }, { "id": "proxies_to:route:GET:/api/admin/trr-api/social/ingest/live-status/stream:backend:GET:/api/v1/admin/socials/live-status/stream", "kind": "proxies_to", @@ -22955,6 +23453,8 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "page:/admin/cast-screentime", "page:/admin/design-docs/athletic-articles", "page:/admin/dev-dashboard", + "page:/admin/dev-dashboard/instagram-catalog-backfill-mockup", + "page:/admin/dev-dashboard/instagram-comments-mockup", "page:/admin/dev-dashboard/skills-and-agents", "page:/admin/docs", "page:/admin/games", @@ -22978,10 +23478,6 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "page:/admin/social/reddit/[communitySlug]/[showSlug]/[windowKey]", "page:/admin/social/reddit/[communitySlug]/[showSlug]/[windowKey]/[detailSlug]", "page:/admin/social/reddit/[communitySlug]/[showSlug]/[windowKey]/post/[postId]", - "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]", - "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]", - "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]", - "page:/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]", "page:/admin/survey-responses", "page:/admin/surveys", "page:/admin/surveys/[surveyKey]", @@ -23081,6 +23577,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "route:GET:/api/admin/shows/palette-library", "route:GET:/api/admin/social-posts/[postId]", "route:GET:/api/admin/social/landing", + "route:GET:/api/admin/social/media-queue/snapshots", "route:GET:/api/admin/surveys", "route:GET:/api/admin/surveys/[surveyKey]", "route:GET:/api/admin/surveys/[surveyKey]/cast", @@ -23176,7 +23673,10 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "route:GET:/api/admin/trr-api/shows/[showId]/surveys", "route:GET:/api/admin/trr-api/shows/resolve-slug", "route:GET:/api/admin/trr-api/shows/settings/show-core-auto-refresh", + "route:GET:/api/admin/trr-api/social-growth/calls/[callId]", "route:GET:/api/admin/trr-api/social-growth/cast-comparison/snapshot", + "route:GET:/api/admin/trr-api/social-growth/cookies/health", + "route:GET:/api/admin/trr-api/social-growth/history", "route:GET:/api/admin/trr-api/social/ingest/health-dot", "route:GET:/api/admin/trr-api/social/ingest/live-status", "route:GET:/api/admin/trr-api/social/ingest/live-status/stream", @@ -23187,11 +23687,13 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/posts/[sourceId]/detail", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/review-queue", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/[runId]/progress", + "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/verification", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/collaborators-tags", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/audit-cursor-retries", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/progress", + "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/cookies/health", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/hashtags", "route:GET:/api/admin/trr-api/social/profiles/[platform]/[handle]/hashtags/timeline", @@ -23332,6 +23834,8 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "route:POST:/api/admin/trr-api/social-growth/refresh-batch", "route:POST:/api/admin/trr-api/social/ingest/active-jobs/cancel", "route:POST:/api/admin/trr-api/social/ingest/jobs/[jobId]/debug", + "route:POST:/api/admin/trr-api/social/ingest/media-mirror/drain-account", + "route:POST:/api/admin/trr-api/social/ingest/media-mirror/recover-stale", "route:POST:/api/admin/trr-api/social/ingest/recent-failures/dismiss", "route:POST:/api/admin/trr-api/social/ingest/reset-health", "route:POST:/api/admin/trr-api/social/ingest/stuck-jobs/cancel", @@ -23349,9 +23853,12 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/sync-newer", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/sync-recent", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/audit-cursor-retries", + "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/authenticated-followup", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/cancel", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/guarded-restart", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/jobs/[jobId]/cancel", + "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/public-recovery", + "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/repair-auth", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/runs/[runId]/resume", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/comments/scrape", "route:POST:/api/admin/trr-api/social/profiles/[platform]/[handle]/cookies/refresh", @@ -23412,6 +23919,9 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "backend:GET:/api/v1/admin/operations/[operationId]/stream", "backend:GET:/api/v1/admin/operations/health", "backend:GET:/api/v1/admin/people/[personId]/socialblade", + "backend:GET:/api/v1/admin/people/socialblade/calls/[safeCallId]", + "backend:GET:/api/v1/admin/people/socialblade/cookies/health", + "backend:GET:/api/v1/admin/people/socialblade/history", "backend:GET:/api/v1/admin/person/[personId]/fandom", "backend:GET:/api/v1/admin/shows/[showId]/bravo/news", "backend:GET:/api/v1/admin/shows/[showId]/bravo/videos", @@ -23512,6 +24022,7 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "repo:src/lib/server/admin/networks-streaming-repository.ts::module" ], "polling_loop": [ + "poll:src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx::set-interval-1", "poll:src/app/admin/trr-shows/people/[personId]/PersonPageClient.tsx::set-interval-1", "poll:src/components/admin/social-week/WeekDetailPageView.tsx::set-interval-1", "poll:src/components/admin/social-week/WeekDetailPageView.tsx::set-interval-2" @@ -23598,7 +24109,8 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "contains_polling": [ "contains_polling:component:src/app/admin/trr-shows/people/[personId]/PersonPageClient.tsx::PersonProfilePage:poll:src/app/admin/trr-shows/people/[personId]/PersonPageClient.tsx::set-interval-1", "contains_polling:component:src/components/admin/social-week/WeekDetailPageView.tsx::WeekDetailPage:poll:src/components/admin/social-week/WeekDetailPageView.tsx::set-interval-1", - "contains_polling:component:src/components/admin/social-week/WeekDetailPageView.tsx::WeekDetailPage:poll:src/components/admin/social-week/WeekDetailPageView.tsx::set-interval-2" + "contains_polling:component:src/components/admin/social-week/WeekDetailPageView.tsx::WeekDetailPage:poll:src/components/admin/social-week/WeekDetailPageView.tsx::set-interval-2", + "contains_polling:page:/admin/dev-dashboard/instagram-comments-mockup:poll:src/app/admin/dev-dashboard/instagram-comments-mockup/page.tsx::set-interval-1" ], "calls": [], "proxies_to": [ @@ -23639,6 +24151,9 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "proxies_to:route:GET:/api/admin/trr-api/shows/[showId]/roles:backend:GET:/api/v1/admin/shows/[showId]/roles", "proxies_to:route:GET:/api/admin/trr-api/shows/[showId]/seasons/[seasonNumber]/fandom:backend:GET:/api/v1/admin/shows/[showId]/seasons/[seasonNumber]/fandom", "proxies_to:route:GET:/api/admin/trr-api/shows/settings/show-core-auto-refresh:backend:GET:/api/v1/admin/shows/settings/show-core-auto-refresh", + "proxies_to:route:GET:/api/admin/trr-api/social-growth/calls/[callId]:backend:GET:/api/v1/admin/people/socialblade/calls/[safeCallId]", + "proxies_to:route:GET:/api/admin/trr-api/social-growth/cookies/health:backend:GET:/api/v1/admin/people/socialblade/cookies/health", + "proxies_to:route:GET:/api/admin/trr-api/social-growth/history:backend:GET:/api/v1/admin/people/socialblade/history", "proxies_to:route:GET:/api/admin/trr-api/social/ingest/live-status/stream:backend:GET:/api/v1/admin/socials/live-status/stream", "proxies_to:route:PATCH:/api/admin/networks-streaming/overrides/[id]:backend:PATCH:/api/v1/admin/shows/networks-streaming/overrides/[id]", "proxies_to:route:PATCH:/api/admin/trr-api/brands/families/[familyId]:backend:PATCH:/api/v1/admin/brands/families/[familyId]", @@ -23730,27 +24245,27 @@ export const GENERATED_ADMIN_API_REFERENCE_INVENTORY = { "renders_view": [] }, "summary": { - "totalNodes": 571, - "totalEdges": 200, + "totalNodes": 584, + "totalEdges": 204, "nodesByKind": { - "ui_surface": 59, - "api_route": 389, - "backend_endpoint": 118, + "ui_surface": 57, + "api_route": 400, + "backend_endpoint": 121, "repository_surface": 2, - "polling_loop": 3 + "polling_loop": 4 }, "edgesByKind": { "originates_request": 75, - "contains_polling": 3, + "contains_polling": 4, "calls": 0, - "proxies_to": 118, + "proxies_to": 121, "touches_repository": 4, "renders_view": 0 }, - "automaticNodes": 36, - "pollingNodes": 36, + "automaticNodes": 38, + "pollingNodes": 38, "directPostgresNodes": 2, - "indirectPostgresNodes": 140 + "indirectPostgresNodes": 144 } } } satisfies AdminApiReferenceInventory; diff --git a/apps/web/src/lib/admin/api-references/generator.ts b/apps/web/src/lib/admin/api-references/generator.ts index c5f387b9..6aa5414a 100644 --- a/apps/web/src/lib/admin/api-references/generator.ts +++ b/apps/web/src/lib/admin/api-references/generator.ts @@ -174,15 +174,49 @@ function normalizeDynamicSegmentToken(raw: string): string { return candidate.replace(/^[A-Z]/, (letter) => letter.toLowerCase()); } +function stripPathQueryTemplate(raw: string): string { + let templateDepth = 0; + for (let index = 0; index < raw.length; index += 1) { + const current = raw[index]; + const next = raw[index + 1]; + if (current === "$" && next === "{") { + if (templateDepth === 0) { + const expression = raw.slice(index + 2); + if ( + /^\s*(?:query|search|searchParams|queryParams|urlSearchParams)\b/i.test(expression) || + /^\s*[^}]*\?\s*`?\?/.test(expression) || + /^\s*[^}]*:\s*`?\s*`/.test(expression) + ) { + return raw.slice(0, index); + } + } + templateDepth += 1; + index += 1; + continue; + } + if (current === "}" && templateDepth > 0) { + templateDepth -= 1; + continue; + } + if (templateDepth === 0 && (current === "?" || current === "#")) { + return raw.slice(0, index); + } + } + return raw; +} + function normalizeStringPathPattern(rawLiteral: string): string | null { const unwrapped = rawLiteral.trim().replace(/^["'`]/, "").replace(/["'`]$/, ""); - const normalizedSegments = unwrapped.replace(/\$\{([^}]+)\}/g, (_match, expression: string) => { + const pathOnlyLiteral = stripPathQueryTemplate(unwrapped); + const normalizedSegments = pathOnlyLiteral.replace(/\$\{([^}]+)\}/g, (_match, expression: string) => { return `[${normalizeDynamicSegmentToken(expression)}]`; }); if (!normalizedSegments.startsWith("/")) { return null; } - const withoutTemplateQueries = normalizedSegments.replace(/\[[^\]]*(?:query|search|params?)[^\]]*\]$/i, ""); + const withoutTemplateQueries = normalizedSegments + .replace(/[?#].*$/, "") + .replace(/\[[^\]]*(?:query|search|searchParams|queryParams|urlSearchParams)[^\]]*\]$/i, ""); return normalizePathPattern(withoutTemplateQueries); } diff --git a/apps/web/src/lib/admin/shared-live-resource.ts b/apps/web/src/lib/admin/shared-live-resource.ts index f9c303e0..a376f475 100644 --- a/apps/web/src/lib/admin/shared-live-resource.ts +++ b/apps/web/src/lib/admin/shared-live-resource.ts @@ -71,6 +71,14 @@ type SharedResourceConfig = | ({ mode: "sse" } & SharedSseConfig) | ({ mode: "manualRefetch" } & SharedManualConfig); +const mergeSharedPollRequests = ( + current: SharedPollRequest | null, + next: SharedPollRequest, +): SharedPollRequest => ({ + cause: next.cause ?? current?.cause ?? "manual", + forceRefresh: Boolean(current?.forceRefresh || next.forceRefresh), +}); + const areSharedLiveSnapshotsEqual = ( left: SharedLiveSnapshot, right: SharedLiveSnapshot, @@ -160,12 +168,15 @@ class SharedLiveResourceCoordinator { private readonly snapshotKey: string; private readonly channelName: string; private readonly channel: BroadcastChannel | null; + private readonly onEmpty: (coordinator: SharedLiveResourceCoordinator) => void; private config: SharedResourceConfig; private pendingPollRequest: SharedPollRequest | null = null; private consecutiveRetryableErrorCount = 0; + private disposed = false; - constructor(config: SharedResourceConfig) { + constructor(config: SharedResourceConfig, onEmpty: (coordinator: SharedLiveResourceCoordinator) => void) { this.config = config; + this.onEmpty = onEmpty; this.key = config.key; this.leaseKey = `trr:shared-live:${this.key}:lease:${SNAPSHOT_VERSION}`; this.snapshotKey = `trr:shared-live:${this.key}:snapshot:${SNAPSHOT_VERSION}`; @@ -204,6 +215,10 @@ class SharedLiveResourceCoordinator { unsubscribe(id: string): void { this.subscribers.delete(id); + if (this.subscribers.size === 0) { + this.onEmpty(this); + return; + } this.reconcile(); } @@ -216,10 +231,18 @@ class SharedLiveResourceCoordinator { requestImmediateRefresh(request?: SharedPollRequest): void { if (!this.shouldRunInThisTab()) return; - this.pendingPollRequest = request ?? { cause: "manual" }; + this.pendingPollRequest = mergeSharedPollRequests( + this.pendingPollRequest, + request ?? { cause: "manual" }, + ); this.clearTimer(); if (this.config.mode === "sse") { this.stopExecutor(); + this.scheduleTick(0); + return; + } + if (this.inFlight) { + return; } this.scheduleTick(0); } @@ -230,10 +253,18 @@ class SharedLiveResourceCoordinator { * avoid leaking coordinator state between cases. */ dispose(): void { + if (this.disposed) return; + this.disposed = true; this.clearTimer(); this.stopExecutor(); this.subscribers.clear(); this.releaseLeaderLease(); + if (typeof window !== "undefined") { + window.removeEventListener("storage", this.handleStorageEvent); + } + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", this.handleVisibilityChange); + } if (this.channel) { try { this.channel.close(); @@ -336,7 +367,7 @@ class SharedLiveResourceCoordinator { if (this.config.mode === "poll") { const nextDelayMs = await this.runPoll(); if (this.shouldRunInThisTab()) { - this.scheduleTick(nextDelayMs ?? this.config.intervalMs); + this.scheduleTick(this.pendingPollRequest ? 0 : nextDelayMs ?? this.config.intervalMs); } return; } @@ -609,7 +640,11 @@ const getCoordinator = (config: SharedResourceConfig): SharedLiveResource existing.updateConfig(config); return existing; } - const coordinator = new SharedLiveResourceCoordinator(config); + const coordinator = new SharedLiveResourceCoordinator(config, (emptyCoordinator) => { + if (registry[config.key] !== emptyCoordinator) return; + emptyCoordinator.dispose(); + delete registry[config.key]; + }); registry[config.key] = coordinator as SharedLiveResourceCoordinator; return coordinator; }; diff --git a/apps/web/src/lib/admin/social-account-profile.ts b/apps/web/src/lib/admin/social-account-profile.ts index a1f694e3..3c442a94 100644 --- a/apps/web/src/lib/admin/social-account-profile.ts +++ b/apps/web/src/lib/admin/social-account-profile.ts @@ -446,6 +446,11 @@ export type SocialAccountCommentsScrapeRequest = max_comments_per_post?: number; refresh_policy?: "stale_or_missing" | "all_saved_posts"; target_filter?: "incomplete"; + comments_load_strategy?: "public_relay" | "instagram_comments_endpoint_cursor" | "cursor_api" | "single_session_load_all"; + comments_worker_count?: number; + comments_target_batch_size?: number; + date_start?: string | null; + date_end?: string | null; allow_inline_dev_fallback?: boolean; dry_run?: boolean; } @@ -453,6 +458,7 @@ export type SocialAccountCommentsScrapeRequest = mode: "single_post"; source_id: string; max_comments_per_post?: number; + comments_load_strategy?: "public_relay" | "instagram_comments_endpoint_cursor" | "cursor_api" | "single_session_load_all"; allow_inline_dev_fallback?: boolean; dry_run?: boolean; }; @@ -481,7 +487,7 @@ export type SocialAccountCommentsScrapeResponse = { timing?: Record | null; } & InstagramCommentsLaunchAuthMetadata; -export type SocialAccountCommentsAuditCursorRetryRow = { +export type SocialAccountCommentsRelayCheckpointRetryRow = { shortcode: string; post_id?: string | null; show_id?: string | null; @@ -506,7 +512,7 @@ export type SocialAccountCommentsAuditCursorRetryRow = { active_job_target_counts?: number[]; }; -export type SocialAccountCommentsAuditCursorRetriesResponse = { +export type SocialAccountCommentsRelayCheckpointRetriesResponse = { ok?: boolean; account?: string; selected_target_source_ids?: string[]; @@ -519,8 +525,8 @@ export type SocialAccountCommentsAuditCursorRetriesResponse = { terms?: string[]; } | null; active_run?: Record | null; - progress_rows?: SocialAccountCommentsAuditCursorRetryRow[]; - rows?: SocialAccountCommentsAuditCursorRetryRow[]; + progress_rows?: SocialAccountCommentsRelayCheckpointRetryRow[]; + rows?: SocialAccountCommentsRelayCheckpointRetryRow[]; mode?: "dry_run" | "enqueue" | string; batch_size?: number; enqueue?: { @@ -532,7 +538,7 @@ export type SocialAccountCommentsAuditCursorRetriesResponse = { failure_reason?: string | null; }; -export type SocialAccountCommentsAuditCursorRetryRequest = { +export type SocialAccountCommentsRelayCheckpointRetryRequest = { limit?: number; shortcodes?: string[]; stop_reasons?: string[]; @@ -543,7 +549,9 @@ export type SocialAccountCommentsAuditCursorRetryRequest = { batch_size?: number; comments_worker_count?: number; max_comments_per_post?: number; - comments_load_strategy?: "cursor_api" | "single_session_load_all"; + comments_load_strategy?: "public_relay" | "instagram_comments_endpoint_cursor" | "cursor_api" | "single_session_load_all"; + date_start?: string | null; + date_end?: string | null; skip_launch_auth_probe?: boolean; attach_to_active_run?: boolean; dispatch_immediately?: boolean; @@ -579,11 +587,21 @@ export type SocialAccountCommentsDryRunPreviewResponse = { incomplete_fill?: boolean | null; target_priority?: string | null; target_source_ids_count?: number; + comments_load_strategy?: "public_relay" | "instagram_comments_endpoint_cursor" | "cursor_api" | "single_session_load_all" | string; + comments_session_scope?: string | null; + date_start?: string | null; + date_end?: string | null; + target_window?: { + date_start?: string | null; + date_end?: string | null; + end_exclusive?: boolean; + } | null; comments_shard_count?: number; comments_sharding_enabled?: boolean; comments_proxy_shard_sessions?: boolean; recommended_comments_shard_count?: number; sample_target_source_ids?: string[]; + strategy_warnings?: Array> | string[]; timing?: Record | null; preview_cache?: Record | string | null; cache?: Record | string | null; @@ -665,6 +683,45 @@ export type SocialAccountCommentsNetworkSpend = { spend_basis?: string | null; }; +export type SocialAccountCatalogCommentsStreaming = { + enabled?: boolean | null; + state?: string | null; + source?: string | null; + comments_run_id?: string | null; + account_handle?: string | null; + source_scope?: string | null; + launch_group_id?: string | null; + worker_count?: number | null; + enable_media_followups?: boolean | null; + next_action?: { + code?: string | null; + label?: string | null; + detail?: string | null; + } | null; + targets_seen?: number | null; + targets_enqueued?: number | null; + targets_skipped_duplicate?: number | null; + append_failures?: number | null; + reconciled_source_ids?: number | null; + last_updated_at?: string | null; + started_at?: string | null; + completed_at?: string | null; + last_enqueue_targets_seen?: number | null; + last_enqueue_targets_enqueued?: number | null; + last_enqueue_requested_at?: string | null; + last_enqueue_completed_at?: string | null; + last_enqueue_lag_ms?: number | null; + max_enqueue_lag_ms?: number | null; + average_enqueue_lag_ms?: number | null; + enqueue_attempt_count?: number | null; + last_batch_source_ids_count?: number | null; + last_append_result?: Record | null; + last_reconcile_result?: Record | null; + last_error?: string | null; + last_conflict?: string | null; + history?: Array>; +}; + export type SocialAccountInstagramAccessProof = { auth_state?: string | null; cookie_state?: string | null; @@ -675,6 +732,30 @@ export type SocialAccountInstagramAccessProof = { proof_label?: string | null; }; +export type SocialAccountCommentsGapPost = { + source_id?: string | null; + shortcode?: string | null; + post_id?: string | null; + post_url?: string | null; + posted_at?: string | null; + status?: string | null; + current_phase?: string | null; + latest_reason?: string | null; + fetch_reason?: string | null; + latest_stop_reason?: string | null; + cursor_stop_reason?: string | null; + reported_comment_count?: number | null; + saved_comment_count?: number | null; + observed_comment_count?: number | null; + missing_comment_gap?: number | null; + has_top_level_cursor?: boolean | null; + reply_resume_count?: number | null; + remaining?: boolean | null; + retryable?: boolean | null; + auth_failed?: boolean | null; + network_stopped?: boolean | null; +}; + export type SocialAccountCommentsTargetProgressRow = { source_id?: string | null; shortcode?: string | null; @@ -709,6 +790,7 @@ export type SocialAccountCommentsRunProgress = { platform: SocialPlatformSlug; account_handle: string; run_status: string; + operational_state?: string | null; created_at?: string | null; started_at?: string | null; completed_at?: string | null; @@ -743,9 +825,9 @@ export type SocialAccountCommentsRunProgress = { top_incomplete_reasons?: Record | Array>; incomplete_reason_counts?: Record; retry_reason_counts?: Record; - largest_remaining_gaps?: Array>; - largest_gaps?: Array>; - incomplete_targets?: Array>; + largest_remaining_gaps?: SocialAccountCommentsGapPost[]; + largest_gaps?: SocialAccountCommentsGapPost[]; + incomplete_targets?: SocialAccountCommentsGapPost[]; recommended_next_action?: string | null; operator_next_action?: string | null; recommended_action?: string | null; @@ -792,7 +874,13 @@ export type SocialAccountCommentsRunProgress = { targeted_retry_target_count?: number | null; network_stopped_target_count?: number | null; network_stopped_target_source_ids?: string[]; - largest_remaining_gaps?: Array>; + public_comments_recovery_pending_target_count?: number | null; + public_comments_recovery_pending_target_source_ids?: string[]; + public_recovery_bucket?: Record | null; + public_comments_approval_required_target_count?: number | null; + public_comments_approval_required_target_source_ids?: string[]; + authenticated_followup_bucket?: Record | null; + largest_remaining_gaps?: SocialAccountCommentsGapPost[]; target_progress_rows?: SocialAccountCommentsTargetProgressRow[]; top_incomplete_reasons?: Record | null; } | null; @@ -813,6 +901,13 @@ export type SocialAccountCommentsRunProgress = { comments_endpoint_probe?: Record | null; comments_endpoint_probe_advisory_active?: boolean | null; manual_auth_required?: boolean | null; + public_comments_recovery_pending_target_count?: number | null; + public_comments_recovery_pending_target_source_ids?: string[]; + public_recovery_bucket?: Record | null; + public_comments_approval_required_target_count?: number | null; + public_comments_approval_required_target_source_ids?: string[]; + authenticated_followup_bucket?: Record | null; + catalog_streaming?: SocialAccountCatalogCommentsStreaming | null; started_at_epoch_seconds?: number | null; updated_at?: string | null; }; @@ -934,7 +1029,7 @@ export type SocialAccountCatalogRun = { completed_at?: string | null; error_message?: string | null; launch_group_id?: string | null; - launch_state?: "pending" | "finalizing" | "ready" | "failed" | "blocked_auth" | null; + launch_state?: "pending" | "pending_apply_confirmation" | "finalizing" | "ready" | "failed" | "blocked_auth" | null; selected_tasks?: CatalogBackfillSelectedTask[]; effective_selected_tasks?: CatalogBackfillSelectedTask[]; comments_run_id?: string | null; @@ -1246,7 +1341,7 @@ export type SocialAccountCatalogRunProgressSnapshot = { run_id: string; run_status: string; launch_group_id?: string | null; - launch_state?: "pending" | "finalizing" | "ready" | "failed" | "blocked_auth" | null; + launch_state?: "pending" | "pending_apply_confirmation" | "finalizing" | "ready" | "failed" | "blocked_auth" | null; catalog_action?: SocialAccountCatalogAction | null; catalog_action_scope?: SocialAccountCatalogActionScope | null; date_start?: string | null; @@ -1255,6 +1350,18 @@ export type SocialAccountCatalogRunProgressSnapshot = { instagram_posts_auth_mode?: "anonymous" | "authenticated" | string | null; selected_tasks?: CatalogBackfillSelectedTask[]; effective_selected_tasks?: CatalogBackfillSelectedTask[]; + budget_decision?: Record | null; + adaptive_worker_plan?: Record | null; + runbook_state?: Record | null; + requires_apply_confirmation?: boolean | null; + apply_required?: boolean | null; + apply_run_id?: string | null; + required_confirmation?: string | null; + enable_cap4_canary?: boolean | null; + detail_worker_count?: number | null; + comments_worker_count?: number | null; + comments_enable_media_followups?: boolean | null; + per_stage_timing_ms?: Record | null; pipeline_strategy?: "stage_graph" | string | null; stage_graph?: SocialAccountCatalogStageGraph | null; target_readiness?: SocialAccountCatalogStageGraphNode | null; @@ -1265,6 +1372,22 @@ export type SocialAccountCatalogRunProgressSnapshot = { } | null; comments_started_before_detail_complete?: boolean; comments_blocked_reason?: string | null; + // Comments-skip diagnostics mirror the upstream helper on the run-progress GET and catalog launch responses. + comments_skip_reason?: + | "comments_not_selected" + | "posts_auth_blocked" + | "no_commentable_targets" + | "authenticated_comments_not_requested" + | "comments_running_or_complete" + | string + | null; + comments_skip_detail?: string | null; + comments_operator_action?: string | null; + // Detail-refresh worker reconciliation surfaced by _finalize_social_account_catalog_route_response. + requested_details_worker_count?: number | null; + details_refresh_worker_count?: number | null; + live_apply_binding_cap?: number | null; + worker_cap_note?: string | null; posts_auth_probe?: Record | null; auth_repair_attempted?: boolean; auth_repair_status?: InstagramCommentsAuthRepairStatus; @@ -1300,6 +1423,7 @@ export type SocialAccountCatalogRunProgressSnapshot = { force_network_detail_fetch?: boolean; details_refresh_shard_count?: number | null; comments_run_id?: string | null; + comments_streaming?: SocialAccountCatalogCommentsStreaming | null; attached_followups?: SocialAccountCatalogAttachedFollowups | null; operational_state?: | "blocked_auth" @@ -1506,14 +1630,25 @@ export type CatalogBackfillRequest = { detail_worker_count?: number | null; comments_worker_count?: number | null; comments_enable_media_followups?: boolean | null; + enable_cap4_canary?: boolean | null; + apply_run_id?: string | null; + operator_confirmation?: string | null; }; export type CatalogBackfillLaunchResponse = { run_id?: string | null; status?: string | null; launch_group_id?: string | null; - launch_state?: "pending" | "ready" | "failed" | null; + launch_state?: "pending" | "pending_apply_confirmation" | "finalizing" | "ready" | "failed" | null; launch_task_resolution_pending?: boolean | null; + requires_apply_confirmation?: boolean | null; + apply_required?: boolean | null; + apply_run_id?: string | null; + required_confirmation?: string | null; + runbook_state?: Record | null; + budget_decision?: Record | null; + adaptive_worker_plan?: Record | null; + enable_cap4_canary?: boolean | null; selected_tasks?: CatalogBackfillSelectedTask[]; effective_selected_tasks?: CatalogBackfillSelectedTask[]; post_details_skipped_reason?: "already_materialized" | null; @@ -1522,6 +1657,22 @@ export type CatalogBackfillLaunchResponse = { attached_followups?: SocialAccountCatalogAttachedFollowups | null; catalog_bootstrap_required?: boolean; comments_deferred_until_catalog_complete?: boolean; + // Worker reconciliation + comments-skip diagnostics from _finalize_social_account_catalog_route_response. + requested_details_worker_count?: number | null; + details_refresh_worker_count?: number | null; + live_apply_binding_cap?: number | null; + worker_cap_note?: string | null; + deduped?: boolean; + comments_skip_reason?: + | "comments_not_selected" + | "posts_auth_blocked" + | "no_commentable_targets" + | "authenticated_comments_not_requested" + | "comments_running_or_complete" + | string + | null; + comments_skip_detail?: string | null; + comments_operator_action?: string | null; } & InstagramCommentsLaunchAuthMetadata; export type CatalogSyncRecentRequest = { @@ -1572,10 +1723,28 @@ export type SocialAccountProfileHashtagAssignment = { show_id?: string | null; show_name?: string | null; show_slug?: string | null; + assignment_scope?: "global" | "platform" | null; + platform?: SocialPlatformSlug | null; updated_by?: string | null; updated_at?: string | null; }; +export type SocialHashtagAssignmentBackfillConflict = { + id?: string | null; + hashtag: string; + display_hashtag?: string | null; + distinct_show_count: number; + legacy_assignments: Array<{ + platform?: SocialPlatformSlug | string | null; + account_handle?: string | null; + show_id?: string | null; + season_id?: string | null; + display_hashtag?: string | null; + }>; + resolution_action: string; + resolved_at?: string | null; +}; + export type SocialAccountProfileHashtag = { hashtag: string; display_hashtag?: string | null; @@ -1695,6 +1864,16 @@ export type SocialProfileCookieHealth = { account_handle?: string; shortcode?: string | null; ready: boolean; + public_ready?: boolean | null; + authenticated_ready?: boolean | null; + auth_probe_skipped?: boolean | null; + auth_required_for_hidden_comments?: boolean | null; + comments_auth_blocker?: string | null; + operator_action?: string | null; + rate_limited?: boolean | null; + cooldown_recommended_seconds?: number | null; + cache_hit?: boolean | null; + cache_ttl_seconds?: number | null; status?: string | null; category?: string | null; reason: string | null; diff --git a/apps/web/src/lib/admin/social-timezone.ts b/apps/web/src/lib/admin/social-timezone.ts new file mode 100644 index 00000000..344e8d5d --- /dev/null +++ b/apps/web/src/lib/admin/social-timezone.ts @@ -0,0 +1,89 @@ +export const SOCIAL_TIME_ZONE = "America/New_York"; + +const DATE_TOKEN_RE = /^\d{4}-\d{2}-\d{2}$/; + +type DateParts = { + year: number; + month: number; + day: number; +}; + +const parseDateToken = (value: string): DateParts | null => { + const match = DATE_TOKEN_RE.exec(value); + if (!match) return null; + return { + year: Number(match[0].slice(0, 4)), + month: Number(match[0].slice(5, 7)), + day: Number(match[0].slice(8, 10)), + }; +}; + +const getTimeZoneOffsetMs = (timestampMs: number, timeZone: string): number => { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(timestampMs)); + const values: Record = {}; + for (const part of parts) { + if (part.type === "literal") continue; + values[part.type] = Number(part.value); + } + const zonedAsUtc = Date.UTC( + values.year ?? 0, + (values.month ?? 1) - 1, + values.day ?? 1, + values.hour ?? 0, + values.minute ?? 0, + values.second ?? 0, + ); + return zonedAsUtc - timestampMs; +}; + +const toZonedUtcIso = ( + parts: DateParts, + time: { hour: number; minute: number; second: number; millisecond?: number }, +): string => { + const baseUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + time.hour, + time.minute, + time.second, + time.millisecond ?? 0, + ); + const firstOffset = getTimeZoneOffsetMs(baseUtc, SOCIAL_TIME_ZONE); + let correctedUtc = baseUtc - firstOffset; + const secondOffset = getTimeZoneOffsetMs(correctedUtc, SOCIAL_TIME_ZONE); + if (secondOffset !== firstOffset) { + correctedUtc = baseUtc - secondOffset; + } + return new Date(correctedUtc).toISOString(); +}; + +const addDays = (parts: DateParts, days: number): DateParts => { + const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days, 12, 0, 0)); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }; +}; + +export const buildIsoDayRange = (dayLocal: string): { dateStart: string; dateEnd: string } | null => { + const parsed = parseDateToken(dayLocal); + if (!parsed) return null; + const dateStart = toZonedUtcIso(parsed, { hour: 0, minute: 0, second: 0, millisecond: 0 }); + const nextDay = addDays(parsed, 1); + const nextDateStart = toZonedUtcIso(nextDay, { hour: 0, minute: 0, second: 0, millisecond: 0 }); + return { + dateStart, + dateEnd: new Date(Date.parse(nextDateStart) - 1).toISOString(), + }; +}; diff --git a/apps/web/src/lib/data/shows.ts b/apps/web/src/lib/data/shows.ts index ece04050..059bda4a 100644 --- a/apps/web/src/lib/data/shows.ts +++ b/apps/web/src/lib/data/shows.ts @@ -18,12 +18,14 @@ export const ALL_SHOWS: string[] = [ "Love Is Blind", "Too Hot to Handle", "The Circle", + "Love Island USA", "Amazing Race", "The Challenge", "Big Brother", "Below Deck", "Below Deck Mediterranean", "Below Deck Sailing Yacht", + "Summer House", "Vanderpump Rules", "Real Housewives of Atlanta", "Real Housewives of Beverly Hills", @@ -31,6 +33,7 @@ export const ALL_SHOWS: string[] = [ "Real Housewives of New Jersey", "Real Housewives of New York", "Real Housewives of Potomac", + "Real Housewives of Rhode Island", "Real Housewives of Salt Lake City", "Selling Sunset", "The Traitors", diff --git a/apps/web/src/lib/fonts/brand-fonts/scoring-profiles.ts b/apps/web/src/lib/fonts/brand-fonts/scoring-profiles.ts new file mode 100644 index 00000000..a1c39444 --- /dev/null +++ b/apps/web/src/lib/fonts/brand-fonts/scoring-profiles.ts @@ -0,0 +1,92 @@ +import type { ScoreBreakdown, ScoreBreakdownProfile } from "./types.ts"; + +export const CLASSIFICATION_MAX = 18; +export const ROLE_MAX = 20; +export const WIDTH_MAX = 15; +export const WEIGHT_COVERAGE_MAX = 15; +export const STYLE_SUPPORT_MAX = 10; +export const TRAIT_COMPATIBILITY_MAX = 10; +export const FAMILY_AFFINITY_MAX = 20; +export const VISUAL_AFFINITY_MAX = 15; +export const RISK_PENALTY_MAX = 20; + +type StructuralContribution = + | "classification" + | "role" + | "width" + | "weightCoverage" + | "styleSupport" + | "traitCompatibility"; + +export type WeightedProfile = { + name: ScoreBreakdownProfile; + structuralWeights: Record; + identityWeight: number; + visualWeight: number; +}; + +export type ScoreBreakdownMaxima = Record< + keyof Omit, + number +>; + +export const WEIGHTED_PROFILES: Record = { + "explicit-mapping-visual": { + name: "explicit-mapping-visual", + structuralWeights: { + classification: 6, + role: 10, + width: 10, + weightCoverage: 7, + styleSupport: 4, + traitCompatibility: 3, + }, + identityWeight: 5, + visualWeight: 55, + }, + "balanced-visual": { + name: "balanced-visual", + structuralWeights: { + classification: 12, + role: 18, + width: 14, + weightCoverage: 10, + styleSupport: 6, + traitCompatibility: 5, + }, + identityWeight: 10, + visualWeight: 25, + }, + "metadata-only": { + name: "metadata-only", + structuralWeights: { + classification: 17, + role: 25, + width: 19, + weightCoverage: 14, + styleSupport: 8, + traitCompatibility: 7, + }, + identityWeight: 10, + visualWeight: 0, + }, +}; + +export function getScoreBreakdownMaxima(profileName: ScoreBreakdownProfile): ScoreBreakdownMaxima { + const profile = WEIGHTED_PROFILES[profileName]; + const structuralTotal = Object.values(profile.structuralWeights).reduce( + (total, value) => total + value, + 0, + ); + + return { + ...profile.structuralWeights, + familyName: profile.identityWeight, + visualAffinity: profile.visualWeight, + riskPenalty: RISK_PENALTY_MAX, + structuralTotal, + identityTotal: profile.identityWeight, + visualTotal: profile.visualWeight, + penaltyTotal: RISK_PENALTY_MAX, + }; +} diff --git a/apps/web/src/lib/fonts/brand-fonts/scoring.ts b/apps/web/src/lib/fonts/brand-fonts/scoring.ts index b8485ef9..e4edc513 100644 --- a/apps/web/src/lib/fonts/brand-fonts/scoring.ts +++ b/apps/web/src/lib/fonts/brand-fonts/scoring.ts @@ -16,7 +16,6 @@ import type { NormalizedR2FontRecord, RationaleChip, ScoreBreakdown, - ScoreBreakdownProfile, ScoringMode, VisualEvidenceHealth, } from "./types.ts"; @@ -29,71 +28,19 @@ import { normalizeFontKey, roleSupportsDisplay, } from "./normalization.ts"; - -const CLASSIFICATION_MAX = 18; -const ROLE_MAX = 20; -const WIDTH_MAX = 15; -const WEIGHT_COVERAGE_MAX = 15; -const STYLE_SUPPORT_MAX = 10; -const TRAIT_COMPATIBILITY_MAX = 10; -const FAMILY_AFFINITY_MAX = 20; -const VISUAL_AFFINITY_MAX = 15; - -type WeightedProfile = { - name: ScoreBreakdownProfile; - structuralWeights: { - classification: number; - role: number; - width: number; - weightCoverage: number; - styleSupport: number; - traitCompatibility: number; - }; - identityWeight: number; - visualWeight: number; -}; - -const WEIGHTED_PROFILES: Record = { - "explicit-mapping-visual": { - name: "explicit-mapping-visual", - structuralWeights: { - classification: 6, - role: 10, - width: 10, - weightCoverage: 7, - styleSupport: 4, - traitCompatibility: 3, - }, - identityWeight: 5, - visualWeight: 55, - }, - "balanced-visual": { - name: "balanced-visual", - structuralWeights: { - classification: 12, - role: 18, - width: 14, - weightCoverage: 10, - styleSupport: 6, - traitCompatibility: 5, - }, - identityWeight: 10, - visualWeight: 25, - }, - "metadata-only": { - name: "metadata-only", - structuralWeights: { - classification: 17, - role: 25, - width: 19, - weightCoverage: 14, - styleSupport: 8, - traitCompatibility: 7, - }, - identityWeight: 10, - visualWeight: 0, - }, -}; +import { + CLASSIFICATION_MAX, + FAMILY_AFFINITY_MAX, + RISK_PENALTY_MAX, + ROLE_MAX, + STYLE_SUPPORT_MAX, + TRAIT_COMPATIBILITY_MAX, + VISUAL_AFFINITY_MAX, + WEIGHT_COVERAGE_MAX, + WEIGHTED_PROFILES, + WIDTH_MAX, + type WeightedProfile, +} from "./scoring-profiles.ts"; export type RankingScoringOptions = { glyphComparisonArtifact?: GlyphComparisonArtifact | null; @@ -230,15 +177,15 @@ function familyNameScore( function riskPenalty(source: BrandFontRecord, candidate: NormalizedR2FontRecord, rule: BrandFontMatchRule | null): number { if (rule?.demoteDisplayOnlyForBody && source.roleType === "body" && candidate.bodyRisk === "high") { - return 20; + return RISK_PENALTY_MAX; } if (source.roleType === "body") { - return candidate.bodyRisk === "high" ? 20 : candidate.bodyRisk === "medium" ? 10 : 0; + return candidate.bodyRisk === "high" ? RISK_PENALTY_MAX : candidate.bodyRisk === "medium" ? 10 : 0; } if (roleSupportsDisplay(source.roleType)) { - return candidate.displayRisk === "high" ? 20 : candidate.displayRisk === "medium" ? 10 : 0; + return candidate.displayRisk === "high" ? RISK_PENALTY_MAX : candidate.displayRisk === "medium" ? 10 : 0; } - return candidate.bodyRisk === "high" ? 20 : candidate.bodyRisk === "medium" ? 10 : 0; + return candidate.bodyRisk === "high" ? RISK_PENALTY_MAX : candidate.bodyRisk === "medium" ? 10 : 0; } function dedupe(values: readonly T[]): T[] { @@ -275,7 +222,10 @@ function buildLookup( glyphArtifact: GlyphComparisonArtifact | null | undefined, expectedInputHash?: string, ) { - return buildGlyphComparisonLookup(glyphArtifact ?? GENERATED_GLYPH_COMPARISON_ARTIFACT, expectedInputHash); + return buildGlyphComparisonLookup( + glyphArtifact === undefined ? GENERATED_GLYPH_COMPARISON_ARTIFACT : glyphArtifact, + expectedInputHash, + ); } function visualAffinityScore( @@ -486,7 +436,7 @@ function scoreCandidate( familyName, }, currentSubstituteTop, - scoringMode: hasRecordVisualEvidence ? "visual+metadata" : "metadata-only", + scoringMode: visual.pair ? visual.scoringMode : "metadata-only", visualDiagnostics: visual.pair ? { aggregateVisualAffinity: visual.aggregateVisualAffinity, @@ -579,6 +529,7 @@ export function buildBrandFontMatchResults( matchSource: candidate.matchSource, scoreBreakdown: candidate.scoreBreakdown, scoringMode: candidate.scoringMode, + visualDiagnostics: candidate.visualDiagnostics, })); const resultScoringMode: ScoringMode = rankedMatches.some((match) => match.scoringMode === "visual+metadata") diff --git a/apps/web/src/lib/server/admin/dev-dashboard-service.ts b/apps/web/src/lib/server/admin/dev-dashboard-service.ts index 0ae1bf88..4b8524b5 100644 --- a/apps/web/src/lib/server/admin/dev-dashboard-service.ts +++ b/apps/web/src/lib/server/admin/dev-dashboard-service.ts @@ -1,8 +1,10 @@ import "server-only"; import { readdir, readFile, stat, open } from "node:fs/promises"; +import type { Dirent } from "node:fs"; import { join } from "node:path"; import os from "node:os"; +import { getPortlessStatus, type PortlessStatusSnapshot } from "@/lib/server/admin/portless-status"; import { safeExec } from "@/lib/server/admin/shell-exec"; export interface BranchInfo { @@ -97,9 +99,57 @@ export interface OutstandingTasks { claudePlans: ClaudePlanItem[]; } +export interface VercelPreviewReadinessCheck { + status: number | null; + stdout: string; + stderr: string; + enabled: boolean | null; +} + +export interface VercelPreviewReadiness { + artifactPath: string; + generatedAt: string | null; + projectName: string | null; + teamSlug: string | null; + teamId: string | null; + activeProjectDir: string | null; + latestDeploymentUrl: string | null; + webAnalyticsEnabled: boolean | null; + speedInsightsEnabled: boolean | null; + checks: { + webAnalytics: VercelPreviewReadinessCheck | null; + speedInsights: VercelPreviewReadinessCheck | null; + deployments: VercelPreviewReadinessCheck | null; + }; + errors: string[]; +} + +export interface VercelCleanupLink { + ok: boolean; + classification: string; + projectDir: string; + projectFile: string; + projectName: string; + projectId: string; + teamId: string; + cleanupPath: string; + error?: string; +} + +export interface VercelCleanupDoctor { + ok: boolean; + expectedName: string; + expectedId: string; + links: VercelCleanupLink[]; + errors: string[]; +} + export interface DevDashboardData { repos: RepoStatus[]; tasks: OutstandingTasks; + portlessStatus: PortlessStatusSnapshot; + vercelPreviewReadiness: VercelPreviewReadiness | null; + vercelCleanupDoctor: VercelCleanupDoctor; generatedAt: string; } @@ -122,12 +172,252 @@ const REPOS: RepoConfig[] = [ }, ]; +const WORKSPACE_ROOT = "/Users/thomashulihan/Projects/TRR"; +const TRR_APP_ROOT = "/Users/thomashulihan/Projects/TRR/TRR-APP"; +const VERCEL_PREVIEW_READY_RELATIVE_PATH = ".logs/workspace/vercel-preview-ready/latest.json"; +const EXPECTED_VERCEL_PROJECT_NAME = "trr-app"; +const EXPECTED_VERCEL_PROJECT_ID = "prj_MHpStkwr26rV5kjt0f80zqhwZpAs"; +const KNOWN_STALE_VERCEL_PROJECTS = new Map([ + ["web|prj_0nWn8xpm9ikhcvhzE3ma4jUXTe1p", "stale-old-web-project"], +]); +const VERCEL_SCAN_PRUNED_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", ".turbo", ".venv", "__pycache__"]); + function clipError(text: string, maxLen = 400) { const trimmed = text.trim(); if (trimmed.length <= maxLen) return trimmed; return `${trimmed.slice(0, maxLen)}…`; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readOptionalString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function readOptionalNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function parseEnabledFromCliOutput(output: string): boolean | null { + const start = output.indexOf("{"); + const end = output.lastIndexOf("}"); + if (start === -1 || end === -1 || end < start) { + return null; + } + + try { + const parsed: unknown = JSON.parse(output.slice(start, end + 1)); + if (!isRecord(parsed)) return null; + return typeof parsed.enabled === "boolean" ? parsed.enabled : null; + } catch { + return null; + } +} + +function parseVercelPreviewCheck(value: unknown): VercelPreviewReadinessCheck | null { + if (!isRecord(value)) return null; + const stdout = typeof value.stdout === "string" ? value.stdout : ""; + return { + status: readOptionalNumber(value.status), + stdout, + stderr: typeof value.stderr === "string" ? value.stderr : "", + enabled: parseEnabledFromCliOutput(stdout), + }; +} + +function emptyVercelPreviewReadiness(artifactPath: string, errors: string[]): VercelPreviewReadiness { + return { + artifactPath, + generatedAt: null, + projectName: null, + teamSlug: null, + teamId: null, + activeProjectDir: null, + latestDeploymentUrl: null, + webAnalyticsEnabled: null, + speedInsightsEnabled: null, + checks: { + webAnalytics: null, + speedInsights: null, + deployments: null, + }, + errors, + }; +} + +export async function readVercelPreviewReadinessArtifact( + workspaceRoot = WORKSPACE_ROOT, +): Promise { + const artifactPath = join(workspaceRoot, VERCEL_PREVIEW_READY_RELATIVE_PATH); + + let raw: string; + try { + raw = await readFile(artifactPath, "utf-8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + return emptyVercelPreviewReadiness(artifactPath, [ + `Failed to read preview readiness artifact: ${(error as Error).message}`, + ]); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + return emptyVercelPreviewReadiness(artifactPath, [ + `Preview readiness artifact JSON parse failed: ${(error as Error).message}`, + ]); + } + + if (!isRecord(parsed)) { + return emptyVercelPreviewReadiness(artifactPath, ["Preview readiness artifact is not a JSON object."]); + } + + const checks = isRecord(parsed.checks) ? parsed.checks : {}; + const webAnalytics = parseVercelPreviewCheck(checks.webAnalytics); + const speedInsights = parseVercelPreviewCheck(checks.speedInsights); + const deployments = parseVercelPreviewCheck(checks.deployments); + const errors: string[] = []; + + if (webAnalytics && webAnalytics.status !== null && webAnalytics.status !== 0) { + errors.push(`Web Analytics check exited ${webAnalytics.status}: ${clipError(webAnalytics.stderr || webAnalytics.stdout)}`); + } + if (speedInsights && speedInsights.status !== null && speedInsights.status !== 0) { + errors.push(`Speed Insights check exited ${speedInsights.status}: ${clipError(speedInsights.stderr || speedInsights.stdout)}`); + } + if (deployments && deployments.status !== null && deployments.status !== 0) { + errors.push(`Deployments check exited ${deployments.status}: ${clipError(deployments.stderr || deployments.stdout)}`); + } + + return { + artifactPath, + generatedAt: readOptionalString(parsed.generatedAt), + projectName: readOptionalString(parsed.projectName), + teamSlug: readOptionalString(parsed.teamSlug), + teamId: readOptionalString(parsed.teamId), + activeProjectDir: readOptionalString(parsed.activeProjectDir), + latestDeploymentUrl: readOptionalString(parsed.latestDeploymentUrl), + webAnalyticsEnabled: webAnalytics?.enabled ?? null, + speedInsightsEnabled: speedInsights?.enabled ?? null, + checks: { + webAnalytics, + speedInsights, + deployments, + }, + errors, + }; +} + +function classifyVercelProjectLink(name: string, projectId: string) { + if (name === EXPECTED_VERCEL_PROJECT_NAME && projectId === EXPECTED_VERCEL_PROJECT_ID) { + return { ok: true, classification: "project-of-record" }; + } + const knownStale = KNOWN_STALE_VERCEL_PROJECTS.get(`${name}|${projectId}`); + if (knownStale) { + return { ok: false, classification: knownStale }; + } + if (name === EXPECTED_VERCEL_PROJECT_NAME) { + return { ok: false, classification: "expected-name-wrong-id" }; + } + if (name === "web") { + return { ok: false, classification: "stale-web-project-name" }; + } + return { ok: false, classification: "unknown-project-link" }; +} + +async function collectVercelProjectFiles(root: string, projectFiles: string[] = []): Promise { + let entries: Dirent[]; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return projectFiles; + } + + const hasProjectJson = entries.some((entry) => entry.isFile() && entry.name === "project.json"); + if (root.endsWith("/.vercel") && hasProjectJson) { + projectFiles.push(join(root, "project.json")); + return projectFiles; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (VERCEL_SCAN_PRUNED_DIRS.has(entry.name)) continue; + await collectVercelProjectFiles(join(root, entry.name), projectFiles); + } + return projectFiles; +} + +export async function readVercelCleanupDoctor(scanRoot = TRR_APP_ROOT): Promise { + const links: VercelCleanupLink[] = []; + const errors: string[] = []; + const projectFiles = await collectVercelProjectFiles(scanRoot); + + for (const projectFile of projectFiles.sort()) { + const projectDir = projectFile.replace(/\/\.vercel\/project\.json$/, ""); + const cleanupPath = join(projectDir, ".vercel"); + try { + const parsed: unknown = JSON.parse(await readFile(projectFile, "utf-8")); + if (!isRecord(parsed)) { + links.push({ + ok: false, + classification: "unreadable-project-link", + projectDir, + projectFile, + projectName: "", + projectId: "", + teamId: "", + cleanupPath, + error: "project.json is not a JSON object", + }); + continue; + } + + const projectName = readOptionalString(parsed.projectName) ?? ""; + const projectId = readOptionalString(parsed.projectId) ?? ""; + const teamId = readOptionalString(parsed.orgId) ?? readOptionalString(parsed.teamId) ?? ""; + const classified = classifyVercelProjectLink(projectName, projectId); + links.push({ + ok: classified.ok, + classification: classified.classification, + projectDir, + projectFile, + projectName, + projectId, + teamId, + cleanupPath, + }); + } catch (error) { + links.push({ + ok: false, + classification: "unreadable-project-link", + projectDir, + projectFile, + projectName: "", + projectId: "", + teamId: "", + cleanupPath, + error: (error as Error).message, + }); + } + } + + if (links.length === 0) { + errors.push(`No local Vercel project links found under ${scanRoot}.`); + } + + return { + ok: links.length > 0 && links.every((link) => link.ok), + expectedName: EXPECTED_VERCEL_PROJECT_NAME, + expectedId: EXPECTED_VERCEL_PROJECT_ID, + links, + errors, + }; +} + function parseBranchRefs(output: string, currentBranch: string): BranchInfo[] { const results: BranchInfo[] = []; @@ -791,12 +1081,19 @@ export async function getDevDashboardData(): Promise { const repoStatusesPromise = Promise.allSettled(REPOS.map((repo) => collectRepoStatus(repo))); const taskPlansPromise = Promise.allSettled(REPOS.map((repo) => collectTaskPlans(repo))); const claudePlansPromise = collectClaudePlans(); - - const [repoStatusesSettled, taskPlansSettled, claudePlans] = await Promise.all([ - repoStatusesPromise, - taskPlansPromise, - claudePlansPromise, - ]); + const portlessStatusPromise = getPortlessStatus(); + const vercelPreviewReadinessPromise = readVercelPreviewReadinessArtifact(); + const vercelCleanupDoctorPromise = readVercelCleanupDoctor(); + + const [repoStatusesSettled, taskPlansSettled, claudePlans, portlessStatus, vercelPreviewReadiness, vercelCleanupDoctor] = + await Promise.all([ + repoStatusesPromise, + taskPlansPromise, + claudePlansPromise, + portlessStatusPromise, + vercelPreviewReadinessPromise, + vercelCleanupDoctorPromise, + ]); const repos: RepoStatus[] = []; for (let i = 0; i < REPOS.length; i += 1) { @@ -842,6 +1139,9 @@ export async function getDevDashboardData(): Promise { taskPlans: filteredTaskPlans, claudePlans, }, + portlessStatus, + vercelPreviewReadiness, + vercelCleanupDoctor, generatedAt, }; } diff --git a/apps/web/src/lib/server/admin/portless-status.ts b/apps/web/src/lib/server/admin/portless-status.ts index 3bcb8d40..4c510e52 100644 --- a/apps/web/src/lib/server/admin/portless-status.ts +++ b/apps/web/src/lib/server/admin/portless-status.ts @@ -1,39 +1,9 @@ import "server-only"; -import { - PORTLESS_ADMIN_DASHBOARD_URL, - PORTLESS_API_ORIGIN, - PORTLESS_APP_ORIGIN, -} from "@/lib/admin/admin-url-defaults"; +import { PORTLESS_STATUS_ROUTES } from "@/lib/admin/admin-url-defaults"; import { getWorkspaceRoot, safeExec } from "@/lib/server/admin/shell-exec"; const TRR_ROUTE_NAMES = new Set(["trr", "admin.trr", "api.trr", "wordle.trr"]); -const EXPECTED_ROUTES = [ - { - id: "trr", - label: "App", - url: PORTLESS_APP_ORIGIN, - expectedPath: "/", - }, - { - id: "admin.trr", - label: "Admin", - url: PORTLESS_ADMIN_DASHBOARD_URL, - expectedPath: "/admin", - }, - { - id: "api.trr", - label: "API", - url: `${PORTLESS_API_ORIGIN}/health/live`, - expectedPath: "/health/live", - }, - { - id: "wordle.trr", - label: "Wordle", - url: "https://wordle.trr.localhost", - expectedPath: "/", - }, -] as const; export interface PortlessRouteStatus { url: string; @@ -42,9 +12,24 @@ export interface PortlessRouteStatus { kind: string; } +export interface PortlessServiceStatus { + manager_state: string | null; + installed: boolean | null; + proxy_on_443: boolean | null; + https: boolean | null; + tld: string | null; + lan_mode: boolean | null; + wildcard: boolean | null; + state_directory: string | null; + service_entry: string | null; + raw_output: string; + raw_error?: string; +} + export interface PortlessStatusSnapshot { status: "ok" | "unavailable"; checked_at: string; + service: PortlessServiceStatus | null; routes: Array<{ id: string; label: string; @@ -71,6 +56,35 @@ function routeNameFromUrl(url: string) { } } +function readServiceValue(output: string, label: string): string | null { + const match = output.match(new RegExp(`^\\s*${label}:\\s*(.+?)\\s*$`, "im")); + return match?.[1]?.trim() || null; +} + +function readServiceBoolean(output: string, label: string): boolean | null { + const value = readServiceValue(output, label)?.toLowerCase(); + if (!value) return null; + if (["yes", "true", "enabled", "responding", "running"].includes(value)) return true; + if (["no", "false", "disabled", "not responding", "stopped"].includes(value)) return false; + return null; +} + +export function parsePortlessServiceStatus(output: string, error?: string): PortlessServiceStatus { + return { + manager_state: readServiceValue(output, "Manager state"), + installed: readServiceBoolean(output, "Installed"), + proxy_on_443: readServiceBoolean(output, "Proxy on 443"), + https: readServiceBoolean(output, "HTTPS"), + tld: readServiceValue(output, "TLD"), + lan_mode: readServiceBoolean(output, "LAN mode"), + wildcard: readServiceBoolean(output, "Wildcard"), + state_directory: readServiceValue(output, "State directory"), + service_entry: readServiceValue(output, "Service entry"), + raw_output: output, + raw_error: error, + }; +} + export function parsePortlessList(output: string): PortlessRouteStatus[] { return output .split("\n") @@ -94,10 +108,11 @@ export function buildPortlessStatusSnapshot(input: { routes: PortlessRouteStatus[]; exitCode: number; stderr?: string; + service?: PortlessServiceStatus | null; checkedAt?: string; }): PortlessStatusSnapshot { const routeByName = new Map(input.routes.map((route) => [route.name, route])); - const expectedRoutes = EXPECTED_ROUTES.map((expected) => { + const expectedRoutes = PORTLESS_STATUS_ROUTES.map((expected) => { const route = routeByName.get(expected.id); const wildcardFallback = expected.id === "admin.trr" && !route && routeByName.get("trr")?.kind !== "alias" @@ -119,6 +134,7 @@ export function buildPortlessStatusSnapshot(input: { return { status: input.exitCode === 0 ? "ok" : "unavailable", checked_at: input.checkedAt ?? new Date().toISOString(), + service: input.service ?? null, routes: expectedRoutes, static_alias_count: staticAliasCount, uses_static_aliases: staticAliasCount > 0, @@ -129,11 +145,15 @@ export function buildPortlessStatusSnapshot(input: { } export async function getPortlessStatus(): Promise { - const result = await safeExec("portless", ["list"], getWorkspaceRoot(), 5_000); + const [result, serviceResult] = await Promise.all([ + safeExec("portless", ["list"], getWorkspaceRoot(), 5_000), + safeExec("portless", ["service", "status"], getWorkspaceRoot(), 5_000), + ]); const routes = result.exitCode === 0 ? parsePortlessList(result.stdout) : []; return buildPortlessStatusSnapshot({ routes, exitCode: result.exitCode, stderr: result.stderr, + service: parsePortlessServiceStatus(serviceResult.stdout, serviceResult.exitCode === 0 ? undefined : serviceResult.stderr), }); } diff --git a/apps/web/src/lib/server/postgres.ts b/apps/web/src/lib/server/postgres.ts index 8eb752d0..b169581d 100644 --- a/apps/web/src/lib/server/postgres.ts +++ b/apps/web/src/lib/server/postgres.ts @@ -43,6 +43,18 @@ const parseConnectionPort = (connectionString: string): string | null => { } }; +export const resolvePgPoolConnectionString = (connectionString: string): string => { + try { + const url = new URL(connectionString); + for (const param of ["sslmode", "sslcert", "sslkey", "sslrootcert"]) { + url.searchParams.delete(param); + } + return url.toString(); + } catch { + return connectionString; + } +}; + const resolveCaBundle = (env: EnvLike = process.env): string | undefined => { const inline = env.DATABASE_SSL_CA; if (inline && inline.trim().length > 0) { @@ -448,7 +460,7 @@ const getPool = (): Pool => { const applicationName = resolvePostgresApplicationName(process.env); const pool = new Pool({ - connectionString, + connectionString: resolvePgPoolConnectionString(connectionString), ssl: resolvePostgresSslConfig(connectionString, process.env), application_name: applicationName, max, diff --git a/apps/web/src/lib/trr-app-flags.ts b/apps/web/src/lib/trr-app-flags.ts new file mode 100644 index 00000000..27fa9835 --- /dev/null +++ b/apps/web/src/lib/trr-app-flags.ts @@ -0,0 +1,90 @@ +type FlagEnv = Record; + +type FlagDefault = "off" | "on"; + +export type TrrAppFlagDefinition = { + key: Key; + envVar: string; + default: FlagDefault; + description: string; +}; + +export const TRR_APP_FLAGS = { + adminSocialIngestionUi: { + key: "adminSocialIngestionUi", + envVar: "NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_INGESTION_UI", + default: "off", + description: "Shows experimental admin social-ingestion controls.", + }, + adminSocialScraperTriggers: { + key: "adminSocialScraperTriggers", + envVar: "NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_SCRAPER_TRIGGERS", + default: "off", + description: "Allows admin UI actions that can trigger social scraper work.", + }, + adminSocialLiveRefresh: { + key: "adminSocialLiveRefresh", + envVar: "NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_LIVE_REFRESH", + default: "off", + description: "Allows admin social views to request fresh live profile data.", + }, + adminCastSocialDashboard: { + key: "adminCastSocialDashboard", + envVar: "NEXT_PUBLIC_TRR_FLAG_ADMIN_CAST_SOCIAL_DASHBOARD", + default: "on", + description: "Keeps the existing cast/social dashboard surface available.", + }, +} as const satisfies Record; + +export type TrrAppFlagKey = keyof typeof TRR_APP_FLAGS; + +export type TrrAppFlagValues = Record; + +const TRUE_VALUES = new Set(["1", "true", "yes", "y", "on", "enabled"]); +const FALSE_VALUES = new Set(["0", "false", "no", "n", "off", "disabled"]); + +const EMPTY_ENV: FlagEnv = {}; + +function getDefaultEnv(): FlagEnv { + return typeof process === "undefined" ? EMPTY_ENV : process.env; +} + +export function getTrrAppFlagDefinitions(): TrrAppFlagDefinition[] { + return Object.values(TRR_APP_FLAGS); +} + +export function parseTrrAppFlagValue(value: string | undefined): boolean | null { + if (value == null) { + return null; + } + + const normalizedValue = value.trim().toLowerCase(); + if (TRUE_VALUES.has(normalizedValue)) { + return true; + } + + if (FALSE_VALUES.has(normalizedValue)) { + return false; + } + + return null; +} + +export function getTrrAppFlagValue( + key: TrrAppFlagKey, + env: FlagEnv = getDefaultEnv(), +): boolean { + const definition = TRR_APP_FLAGS[key]; + const override = parseTrrAppFlagValue(env[definition.envVar]); + + return override ?? definition.default === "on"; +} + +export function getTrrAppFlags(env: FlagEnv = getDefaultEnv()): TrrAppFlagValues { + return Object.fromEntries( + getTrrAppFlagDefinitions().map((definition) => [ + definition.key, + getTrrAppFlagValue(definition.key, env), + ]), + ) as TrrAppFlagValues; +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 15824599..82f8cf80 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -385,6 +385,7 @@ function parseSocialAccountProfilePath(pathname: string): { platform: string; handle: string; tab: string; + variant: string | null; canonicalPath: string; } | null { const segments = toPathSegments(pathname); @@ -394,28 +395,33 @@ function parseSocialAccountProfilePath(pathname: string): { const first = segments[0]?.toLowerCase() ?? ""; const second = segments[1]?.toLowerCase() ?? ""; if (first === "social") { - offset = 1; + offset = second === "profiles" ? 2 : 1; } else if (first === "admin" && second === "social") { - offset = 2; + const third = segments[2]?.toLowerCase() ?? ""; + offset = third === "profiles" ? 3 : 2; } else { return null; } - if (segments.length < offset + 2 || segments.length > offset + 3) return null; + if (segments.length < offset + 2 || segments.length > offset + 4) return null; const platform = segments[offset]?.trim().toLowerCase() ?? ""; const handle = normalizeSocialAccountHandle(segments[offset + 1]); const tab = resolveSocialAccountProfileTab(segments[offset + 2]); + const variant = segments[offset + 3]?.trim().toLowerCase() ?? ""; + if (variant && (tab !== "catalog" || !/^alt-[1-9][0-9]*$/.test(variant))) return null; if (!SOCIAL_ACCOUNT_PROFILE_PLATFORM_SEGMENTS.has(platform) || !handle || !tab) return null; + const basePath = + tab === "stats" + ? `/social/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}` + : `/social/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/${tab}`; return { platform, handle, tab, - canonicalPath: - tab === "stats" - ? `/social/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}` - : `/social/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}/${tab}`, + variant: variant || null, + canonicalPath: variant ? `${basePath}/${encodeURIComponent(variant)}` : basePath, }; } @@ -1005,6 +1011,17 @@ function isApiPath(pathname: string): boolean { return pathname === "/api" || pathname.startsWith("/api/"); } +function buildInternalRewriteUrl(request: NextRequest, rewritePath: string): URL { + const targetUrl = new URL(rewritePath, request.nextUrl.origin); + const requestHostname = normalizeAdminHost(request.nextUrl.hostname); + + if (targetUrl.protocol === "https:" && request.nextUrl.port && isLoopbackAdminHost(requestHostname)) { + targetUrl.protocol = "http:"; + } + + return targetUrl; +} + export function proxy(request: NextRequest): NextResponse { const enforceHost = parseOptionalBoolean(process.env.ADMIN_ENFORCE_HOST) ?? true; if (!enforceHost) return NextResponse.next(); @@ -1047,6 +1064,10 @@ export function proxy(request: NextRequest): NextResponse { return NextResponse.json({ error: "Admin API is not available on this host." }, { status: 403 }); } + if (isApiPath(pathname)) { + return NextResponse.next(); + } + if (onAdminUiPath && !onCanonicalAdminHost && !onPublicUiPath) { if (isInternalAdminRewrite) { return NextResponse.next(); @@ -1069,8 +1090,8 @@ export function proxy(request: NextRequest): NextResponse { } if (onCanonicalAdminHost) { - if (pathname === "/") { - return NextResponse.redirect(new URL(`/admin${request.nextUrl.search}`, adminOrigin ?? request.nextUrl.origin), 307); + if (pathname === "/" && canonicalAdminHost === "admin.trr.localhost") { + return NextResponse.redirect(new URL("/admin", adminOrigin || request.nextUrl.origin), 307); } if (!isInternalAdminRewrite) { @@ -1083,7 +1104,7 @@ export function proxy(request: NextRequest): NextResponse { const rewritePath = mapCanonicalAdminUiRewrite(pathname); if (rewritePath) { - const targetUrl = new URL(rewritePath, request.nextUrl.origin); + const targetUrl = buildInternalRewriteUrl(request, rewritePath); request.nextUrl.searchParams.forEach((value, key) => { if (!targetUrl.searchParams.has(key)) { targetUrl.searchParams.append(key, value); diff --git a/apps/web/tests/admin-api-references-generator.test.ts b/apps/web/tests/admin-api-references-generator.test.ts index 31df4e85..2c9287a6 100644 --- a/apps/web/tests/admin-api-references-generator.test.ts +++ b/apps/web/tests/admin-api-references-generator.test.ts @@ -52,4 +52,32 @@ describe("admin api references generator", () => { ), ).toBe(true); }); + + it("normalizes dynamic backend URL query templates without leaking template source", () => { + expect( + GENERATED_ADMIN_API_REFERENCE_INVENTORY.nodes.some( + (node) => + node.id === "backend:GET:/api/v1/admin/people/socialblade/history" && + node.pathPattern === "/api/v1/admin/people/socialblade/history", + ), + ).toBe(true); + expect( + GENERATED_ADMIN_API_REFERENCE_INVENTORY.nodes.some((node) => + node.pathPattern?.includes("[query]`"), + ), + ).toBe(false); + for (const node of GENERATED_ADMIN_API_REFERENCE_INVENTORY.nodes) { + if (!node.pathPattern) continue; + expect(node.pathPattern).not.toContain("?"); + expect(node.pathPattern).not.toContain("#"); + expect(node.pathPattern).not.toMatch(/\[(?:query|search|params|searchParams)\]/i); + } + expect( + GENERATED_ADMIN_API_REFERENCE_INVENTORY.edges.some( + (edge) => + edge.from === "route:GET:/api/admin/trr-api/social-growth/history" && + edge.to === "backend:GET:/api/v1/admin/people/socialblade/history", + ), + ).toBe(true); + }); }); diff --git a/apps/web/tests/admin-host-middleware.test.ts b/apps/web/tests/admin-host-middleware.test.ts index 7b6adba1..39494bf5 100644 --- a/apps/web/tests/admin-host-middleware.test.ts +++ b/apps/web/tests/admin-host-middleware.test.ts @@ -74,6 +74,34 @@ afterEach(() => { } }); +function followProxyRedirects(initialUrl: string, limit = 5): { locations: string[]; statuses: number[] } { + const locations: string[] = []; + const statuses: number[] = []; + const seen = new Set(); + let currentUrl = initialUrl; + + for (let index = 0; index < limit; index += 1) { + if (seen.has(currentUrl)) { + throw new Error(`Proxy redirect loop detected at ${currentUrl}`); + } + seen.add(currentUrl); + + const response = proxy(new NextRequest(currentUrl)); + statuses.push(response.status); + + const location = response.headers.get("location"); + if ((response.status === 307 || response.status === 308) && location) { + locations.push(location); + currentUrl = location; + continue; + } + + return { locations, statuses }; + } + + throw new Error(`Proxy redirect chain exceeded ${limit} hops from ${initialUrl}`); +} + describe("admin host proxy", () => { it("keeps loopback admin paths local unless the legacy local admin fallback is enabled", () => { process.env.NODE_ENV = "development"; @@ -194,7 +222,7 @@ describe("admin host proxy", () => { ); }); - it("redirects the clean Portless admin host root to the admin dashboard", () => { + it("serves the clean Portless admin host root as the admin dashboard", () => { process.env.NODE_ENV = "development"; process.env.ADMIN_APP_ORIGIN = "https://admin.trr.localhost"; process.env.ADMIN_APP_HOSTS = "admin.trr.localhost,trr.localhost,admin.localhost,localhost,127.0.0.1,[::1]"; @@ -205,6 +233,7 @@ describe("admin host proxy", () => { const response = proxy(request); expect(response.status).toBe(307); + expect(response.headers.get("x-middleware-rewrite")).toBeNull(); expect(response.headers.get("location")).toBe("https://admin.trr.localhost/admin"); }); @@ -223,6 +252,7 @@ describe("admin host proxy", () => { const response = proxy(request); expect(response.status).toBe(307); + expect(response.headers.get("x-middleware-rewrite")).toBeNull(); expect(response.headers.get("location")).toBe("https://admin.trr.localhost/admin"); }); @@ -244,6 +274,24 @@ describe("admin host proxy", () => { ); }); + it("rewrites Portless short admin paths through the local HTTP Next origin", () => { + process.env.NODE_ENV = "development"; + process.env.ADMIN_APP_ORIGIN = "https://admin.trr.localhost"; + process.env.ADMIN_APP_HOSTS = "admin.trr.localhost,trr.localhost,admin.localhost,localhost,127.0.0.1,[::1]"; + process.env.ADMIN_ENFORCE_HOST = "true"; + process.env.ADMIN_STRICT_HOST_ROUTING = "false"; + + const request = new NextRequest("https://localhost:3002/social", { + headers: { + "x-forwarded-host": "admin.trr.localhost", + }, + }); + const response = proxy(request); + + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-rewrite")).toBe("http://localhost:3002/admin/social"); + }); + it("redirects /design-system requests on public host to admin origin", () => { process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; process.env.ADMIN_ENFORCE_HOST = "true"; @@ -315,6 +363,7 @@ describe("admin host proxy", () => { it.each([ "/dev-dashboard", + "/dev-dashboard/instagram-catalog-backfill-mockup", "/docs", "/groups", "/shows/settings", @@ -333,6 +382,18 @@ describe("admin host proxy", () => { expect(response.headers.get("location")).toBe(`http://admin.localhost:3000${pathname}`); }); + it("keeps root show settings aliases on the public placeholder route", () => { + process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; + process.env.ADMIN_ENFORCE_HOST = "true"; + process.env.ADMIN_STRICT_HOST_ROUTING = "false"; + + const request = new NextRequest("http://localhost:3000/rhoslc/settings"); + const response = proxy(request); + + expect(response.status).toBe(200); + expect(response.headers.get("location")).toBeNull(); + }); + it.each([ "/social-media", "/rhoslc/social", @@ -386,7 +447,7 @@ describe("admin host proxy", () => { expect(response.headers.get("x-middleware-next")).toBe("1"); }); - it("redirects the canonical admin-host root to the admin dashboard", () => { + it("serves the canonical admin-host root as the admin dashboard", () => { process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; process.env.ADMIN_ENFORCE_HOST = "true"; process.env.ADMIN_STRICT_HOST_ROUTING = "false"; @@ -394,11 +455,12 @@ describe("admin host proxy", () => { const request = new NextRequest("http://admin.localhost:3000/"); const response = proxy(request); - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe("http://admin.localhost:3000/admin"); + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-rewrite")).toBe("http://admin.localhost:3000/admin"); + expect(response.headers.get("location")).toBeNull(); }); - it("redirects the default local admin UI host root to the admin dashboard in development", () => { + it("serves the default local admin UI host root as the admin dashboard in development", () => { process.env.NODE_ENV = "development"; delete process.env.ADMIN_APP_ORIGIN; delete process.env.ADMIN_APP_HOSTS; @@ -408,8 +470,9 @@ describe("admin host proxy", () => { const request = new NextRequest("http://admin.localhost:3000/"); const response = proxy(request); - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe("http://admin.localhost:3000/admin"); + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-rewrite")).toBe("http://admin.localhost:3000/admin"); + expect(response.headers.get("location")).toBeNull(); }); it("keeps /admin on the canonical admin host as the dashboard route", () => { @@ -437,6 +500,10 @@ describe("admin host proxy", () => { ["/admin/design-docs/overview", "http://admin.localhost:3000/design-docs/overview"], ["/admin/api-references", "http://admin.localhost:3000/api-references"], ["/admin/dev-dashboard/skills-and-agents", "http://admin.localhost:3000/dev-dashboard/skills-and-agents"], + [ + "/admin/dev-dashboard/instagram-catalog-backfill-mockup", + "http://admin.localhost:3000/dev-dashboard/instagram-catalog-backfill-mockup", + ], ])("redirects %s to the canonical admin-host URL", (pathname, expectedLocation) => { process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; process.env.ADMIN_ENFORCE_HOST = "true"; @@ -457,6 +524,10 @@ describe("admin host proxy", () => { ["/design-docs/overview", "http://admin.localhost:3000/admin/design-docs/overview"], ["/api-references", "http://admin.localhost:3000/admin/api-references"], ["/dev-dashboard/skills-and-agents", "http://admin.localhost:3000/admin/dev-dashboard/skills-and-agents"], + [ + "/dev-dashboard/instagram-catalog-backfill-mockup", + "http://admin.localhost:3000/admin/dev-dashboard/instagram-catalog-backfill-mockup", + ], ["/rhoslc", "http://admin.localhost:3000/admin/trr-shows/rhoslc"], ["/rhoslc/credits", "http://admin.localhost:3000/admin/trr-shows/rhoslc/credits"], ["/rhoslc/social", "http://admin.localhost:3000/admin/trr-shows/rhoslc/social"], @@ -505,6 +576,7 @@ describe("admin host proxy", () => { "/social/instagram/bravotv", "/social/instagram/bravotv/socialblade", "/social/instagram/bravotv/catalog", + "/social/instagram/bravotv/catalog/alt-1", "/social/instagram/bravotv/posts", "/social/instagram/bravotv/hashtags", "/social/instagram/bravotv/collaborators-tags", @@ -523,6 +595,24 @@ describe("admin host proxy", () => { expect(response.headers.get("location")).toBeNull(); }); + it.each([ + ["/social/profiles/instagram/bravotv", "http://admin.localhost:3000/social/instagram/bravotv"], + [ + "/social/profiles/instagram/bravotv/comments?run_id=demo", + "http://admin.localhost:3000/social/instagram/bravotv/comments?run_id=demo", + ], + ])("redirects legacy social profile URL %s to its canonical profile route", (pathname, expectedLocation) => { + process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; + process.env.ADMIN_ENFORCE_HOST = "true"; + process.env.ADMIN_STRICT_HOST_ROUTING = "false"; + + const request = new NextRequest(`http://admin.localhost:3000${pathname}`); + const response = proxy(request); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe(expectedLocation); + }); + it.each([ ["/rhoslc/cast", "http://admin.localhost:3000/rhoslc/credits"], ["/rhoslc/s6/cast", "http://admin.localhost:3000/rhoslc/s6/credits"], @@ -650,6 +740,23 @@ describe("admin host proxy", () => { expect(response.headers.get("x-middleware-next")).toBe("1"); }); + it("does not rewrite admin API progress routes into admin UI pages", () => { + process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; + delete process.env.ADMIN_APP_HOSTS; + process.env.ADMIN_ENFORCE_HOST = "true"; + process.env.ADMIN_STRICT_HOST_ROUTING = "false"; + + const request = new NextRequest( + "http://admin.localhost:3000/api/admin/trr-api/social/profiles/instagram/bravotv/comments/runs/5bc3a298-b647-4f2b-ad49-d3d7b95e35a4/progress", + ); + const response = proxy(request); + + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-next")).toBe("1"); + expect(response.headers.get("x-middleware-rewrite")).toBeNull(); + expect(response.headers.get("location")).toBeNull(); + }); + it("keeps /admin requests on the current production host when no explicit admin origin is configured", () => { process.env.NODE_ENV = "production"; delete process.env.ADMIN_APP_ORIGIN; @@ -812,6 +919,20 @@ describe("admin host proxy", () => { expect(response.headers.get("location")).toBe(expectedLocation); }); + it("settles legacy admin show social-week URLs after one 307", () => { + process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; + delete process.env.ADMIN_APP_HOSTS; + process.env.ADMIN_ENFORCE_HOST = "true"; + process.env.ADMIN_STRICT_HOST_ROUTING = "false"; + + const chain = followProxyRedirects( + "http://admin.localhost:3000/admin/trr-shows/rhoslc/seasons/6/social/week/0/youtube", + ); + + expect(chain.locations).toEqual(["http://admin.localhost:3000/rhoslc/s6/social/w0/youtube"]); + expect(chain.statuses).toEqual([307, 200]); + }); + it("redirects internal admin person workspace URLs back to the short canonical /people URL", () => { process.env.ADMIN_APP_ORIGIN = "http://admin.localhost:3000"; delete process.env.ADMIN_APP_HOSTS; diff --git a/apps/web/tests/admin-portless-status.test.ts b/apps/web/tests/admin-portless-status.test.ts index 5da848c0..24320c6c 100644 --- a/apps/web/tests/admin-portless-status.test.ts +++ b/apps/web/tests/admin-portless-status.test.ts @@ -1,7 +1,40 @@ import { describe, expect, it } from "vitest"; -import { buildPortlessStatusSnapshot, parsePortlessList } from "@/lib/server/admin/portless-status"; +import { + buildPortlessStatusSnapshot, + parsePortlessList, + parsePortlessServiceStatus, +} from "@/lib/server/admin/portless-status"; describe("parsePortlessList", () => { + it("parses the Portless service status", () => { + const service = parsePortlessServiceStatus(` +portless service + Manager state: running + Installed: yes + Proxy on 443: responding + HTTPS: yes + TLD: localhost + LAN mode: no + Wildcard: yes + State directory: /Users/thomashulihan/.portless + Service entry: /Library/LaunchDaemons/sh.portless.proxy.plist +`); + + expect(service).toEqual( + expect.objectContaining({ + manager_state: "running", + installed: true, + proxy_on_443: true, + https: true, + tld: "localhost", + lan_mode: false, + wildcard: true, + state_directory: "/Users/thomashulihan/.portless", + service_entry: "/Library/LaunchDaemons/sh.portless.proxy.plist", + }), + ); + }); + it("parses managed and alias routes from portless list output", () => { const routes = parsePortlessList(` Active routes: @@ -51,6 +84,8 @@ Active routes: expect.arrayContaining([ expect.objectContaining({ id: "admin.trr", + url: "https://admin.trr.localhost", + expectedPath: "/", target: "localhost:4192", kind: "wildcard", present: true, diff --git a/apps/web/tests/admin-social-page-auth-bypass.test.tsx b/apps/web/tests/admin-social-page-auth-bypass.test.tsx index 4f21b76a..bbb499c2 100644 --- a/apps/web/tests/admin-social-page-auth-bypass.test.tsx +++ b/apps/web/tests/admin-social-page-auth-bypass.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; const mocks = vi.hoisted(() => ({ @@ -81,6 +81,67 @@ const jsonResponse = ( headers: { "Content-Type": "application/json", ...init.headers }, }); +const buildMediaQueuePayload = () => ({ + queue: { + by_stage: { + media_mirror: { queued: 12, retrying: 1, running: 2 }, + comment_media_mirror: { queued: 3, retrying: 0, running: 1 }, + }, + by_stage_platform: { + media_mirror: { + instagram: { queued: 12, retrying: 1, running: 2 }, + }, + comment_media_mirror: { + instagram: { queued: 3, retrying: 0, running: 1 }, + }, + }, + media_stale_claims: { + total: 2, + by_stage: { media_mirror: 1, comment_media_mirror: 1 }, + by_platform: { instagram: 2 }, + stale_after_seconds: 900, + }, + media_queued_jobs_stale_after_seconds: 900, + media_queued_jobs: [ + { + id: "job-oldest", + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + status: "queued", + stage: "media_mirror", + account_handle: "bravotv", + source_id: "DGk_hLXhy56", + queued_age_seconds: 3600, + stale: true, + created_at: "2026-06-22T13:30:00.000Z", + }, + { + id: "job-newer", + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + status: "queued", + stage: "comment_media_mirror", + account_handle: "bravotv", + source_id: "comment-1", + queued_age_seconds: 120, + stale: false, + created_at: "2026-06-22T14:28:00.000Z", + }, + ], + media_runs: [ + { + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + status: "running", + latest_job_at: "2026-06-22T14:30:00.000Z", + active: 16, + stale: true, + stages: { + media_mirror: { queued: 12, retrying: 1, running: 2 }, + comment_media_mirror: { queued: 3, running: 1 }, + }, + }, + ], + }, +}); + const buildInitialLandingPayload = () => ({ network_sets: [ { @@ -414,6 +475,8 @@ describe("admin social page auth bypass", () => { const landingCacheKey = "trr-admin-social-landing:v7"; beforeEach(() => { + delete process.env.NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_INGESTION_UI; + delete process.env.NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_SCRAPER_TRIGGERS; window.localStorage.clear(); mocks.fetchAdminWithAuth.mockReset(); mocks.fetchAdminWithAuth.mockImplementation( @@ -427,6 +490,59 @@ describe("admin social page auth bypass", () => { } const url = String(input); + if (url.includes("/api/admin/trr-api/social/ingest/queue-status")) { + return jsonResponse(buildMediaQueuePayload()); + } + + if (url === "/api/admin/social/media-queue/snapshots") { + return jsonResponse({ + snapshots: [ + { + name: "20260622T143000Z-77f85ad9-0b32-4607-8ff4-999261bab84c-media_mirror.json", + href: "/api/admin/social/media-queue/snapshots?file=20260622T143000Z-77f85ad9-0b32-4607-8ff4-999261bab84c-media_mirror.json", + createdAt: "2026-06-22T14:30:00.000Z", + runId: "77f85ad9-0b32-4607-8ff4-999261bab84c", + stage: "media_mirror", + }, + ], + }); + } + + if (url === "/api/admin/trr-api/social/ingest/media-mirror/recover-stale") { + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + confirm_recovery: "RECOVER MEDIA MIRROR JOBS", + }); + return jsonResponse({ + ok: true, + recovered_count: 1, + recovered_by_stage: { media_mirror: ["job-oldest"], comment_media_mirror: [] }, + dispatch: { dispatched_job_ids: ["job-2"] }, + }); + } + + if (url === "/api/admin/trr-api/social/ingest/media-mirror/drain-account") { + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + account_handle: "bravotv", + stage: "media_mirror", + recover_limit: 25, + dispatch_limit: 8, + confirm_drain: "DRAIN BRAVO MEDIA", + }); + return jsonResponse({ + ok: true, + before_remaining: 25, + recovered: 25, + dispatched: 8, + after_remaining: 17, + stop_reason: "dispatch_limit_reached", + next_recommended_action: "Run drain again", + }); + } + if (url === "/api/admin/social/landing" && init?.method === "POST") { expect(JSON.parse(String(init.body))).toMatchObject({ target_type: "person", @@ -457,6 +573,11 @@ describe("admin social page auth bypass", () => { ); }); + afterEach(() => { + delete process.env.NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_INGESTION_UI; + delete process.env.NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_SCRAPER_TRIGGERS; + }); + it("updates the cached landing payload after saving a social handle", async () => { let landingLoadCount = 0; const refreshedPayload = buildUpdatedLandingPayload(); @@ -662,6 +783,76 @@ describe("admin social page auth bypass", () => { }); }); + it("renders media queue drilldown and recovers a run-scoped stale queue", async () => { + process.env.NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_INGESTION_UI = "1"; + process.env.NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_SCRAPER_TRIGGERS = "1"; + + render(); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Mirror recovery" })).toBeInTheDocument(); + }); + await waitFor(() => { + expect(screen.getByLabelText("Media recovery run ID")).toHaveValue( + "77f85ad9-0b32-4607-8ff4-999261bab84c", + ); + }); + expect(screen.getByLabelText("Drain media account")).toHaveValue("bravotv"); + expect(screen.getAllByText("Post media").length).toBeGreaterThan(0); + expect(screen.getAllByText("Comment media").length).toBeGreaterThan(0); + expect(screen.getAllByText("1 stale").length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("Oldest queued media jobs")).toBeInTheDocument(); + expect(screen.getByText("DGk_hLXhy56")).toBeInTheDocument(); + expect(screen.getByText("1h")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /77f85ad9/ })).toHaveAttribute( + "href", + "/api/admin/social/media-queue/snapshots?file=20260622T143000Z-77f85ad9-0b32-4607-8ff4-999261bab84c-media_mirror.json", + ); + + fireEvent.change(screen.getByLabelText("Media recovery run"), { + target: { value: "77f85ad9-0b32-4607-8ff4-999261bab84c" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Recover stale/i })); + + await waitFor(() => { + expect(screen.getByText(/Recovered 1 stale media job; dispatched 1\./)).toBeInTheDocument(); + }); + expect(screen.getByText(/1 recovered · 1 dispatched/)).toBeInTheDocument(); + expect(screen.getByText(/Post media 1; 1 dispatch follow-up/)).toBeInTheDocument(); + + const recoveryCall = mocks.fetchAdminWithAuth.mock.calls.find( + ([input, init]) => + String(input) === "/api/admin/trr-api/social/ingest/media-mirror/recover-stale" && + init?.method === "POST", + ); + expect(recoveryCall?.[2]).toMatchObject({ + allowDevAdminBypass: true, + preferredUser: mocks.guardState.user, + }); + + fireEvent.click(screen.getByRole("button", { name: /Drain Bravo media/i })); + + await waitFor(() => { + expect( + screen.getByText( + /Drained @bravotv: 25 before, 25 recovered, 8 dispatched, 17 after\./, + ), + ).toBeInTheDocument(); + }); + expect(screen.getByText(/@bravotv · 77f85ad9-0b32-4607-8ff4-999261bab84c/)).toBeInTheDocument(); + expect(screen.getByText(/Stop: dispatch_limit_reached · Next: Run drain again/)).toBeInTheDocument(); + + const drainCall = mocks.fetchAdminWithAuth.mock.calls.find( + ([input, init]) => + String(input) === "/api/admin/trr-api/social/ingest/media-mirror/drain-account" && + init?.method === "POST", + ); + expect(drainCall?.[2]).toMatchObject({ + allowDevAdminBypass: true, + preferredUser: mocks.guardState.user, + }); + }); + it("renders show-grouped people SocialBlade rows and reveals grouped platform accounts", async () => { const landingPayload = buildInitialLandingPayload(); mocks.fetchAdminWithAuth.mockImplementationOnce(async () => diff --git a/apps/web/tests/brand-font-matching-scoring.test.ts b/apps/web/tests/brand-font-matching-scoring.test.ts index db38d65e..8ee944ac 100644 --- a/apps/web/tests/brand-font-matching-scoring.test.ts +++ b/apps/web/tests/brand-font-matching-scoring.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; import { GENERATED_GLYPH_COMPARISON_ARTIFACT } from "@/lib/fonts/brand-fonts/glyph-comparison.ts"; -import { rankBrandFontCandidates } from "@/lib/fonts/brand-fonts/scoring.ts"; +import { getScoreBreakdownMaxima } from "@/lib/fonts/brand-fonts/scoring-profiles.ts"; +import { buildBrandFontMatchResults, rankBrandFontCandidates } from "@/lib/fonts/brand-fonts/scoring.ts"; import type { BrandFontRecord, BrandFontMatchRule, @@ -10,6 +11,24 @@ import type { } from "@/lib/fonts/brand-fonts/types.ts"; describe("brand font matching scoring", () => { + it("exposes shared score profile maxima for the admin score breakdown UI", () => { + expect(getScoreBreakdownMaxima("explicit-mapping-visual")).toMatchObject({ + classification: 6, + role: 10, + width: 10, + weightCoverage: 7, + styleSupport: 4, + traitCompatibility: 3, + familyName: 5, + visualAffinity: 55, + structuralTotal: 40, + identityTotal: 5, + visualTotal: 55, + penaltyTotal: 20, + }); + expect(getScoreBreakdownMaxima("metadata-only").visualTotal).toBe(0); + }); + it("keeps the regenerated KarnakPro-Book to Gloucester visual score below the old inflated range", () => { const pair = GENERATED_GLYPH_COMPARISON_ARTIFACT.pairs.find( (entry) => @@ -205,6 +224,17 @@ describe("brand font matching scoring", () => { bodyRisk: "high", sourceUrl: "https://example.test/rude.woff2", }, + { + familyName: "Gloucester Condensed", + weightsNormalized: [400, 700], + styles: ["normal"], + widthsNormalized: ["condensed"], + classification: "serif", + traitTokens: ["editorial", "text", "body-safe", "traditional"], + displayRisk: "medium", + bodyRisk: "low", + sourceUrl: "https://example.test/gloucester.woff2", + }, ]; const rules: BrandFontMatchRule[] = [ @@ -296,15 +326,17 @@ describe("brand font matching scoring", () => { record, catalog, rules, - { includeBelowCredible: true, limit: 2 }, + { includeBelowCredible: true, limit: 3 }, { glyphComparisonArtifact: glyphArtifact, expectedInputHash: "hash-1" }, ); const rude = ranked.find((entry) => entry.familyName === "Rude Slab Condensed"); const velino = ranked.find((entry) => entry.familyName === "Velino Compressed Text"); + const gloucester = ranked.find((entry) => entry.familyName === "Gloucester Condensed"); expect(ranked[0]?.familyName).toBe("Rude Slab Condensed"); expect(rude?.scoringMode).toBe("visual+metadata"); + expect(gloucester?.scoringMode).toBe("metadata-only"); expect(rude?.scoreBreakdown.profile).toBe("explicit-mapping-visual"); expect(rude?.scoreBreakdown.visualAffinity ?? 0).toBeGreaterThan( velino?.scoreBreakdown.visualAffinity ?? 0, @@ -313,6 +345,15 @@ describe("brand font matching scoring", () => { rude?.scoreBreakdown.structuralTotal ?? 0, ); expect(rude?.scoreBreakdown.ruleBonus).toBe(0); + + const generatedResult = buildBrandFontMatchResults([record], catalog, rules, { + glyphComparisonArtifact: glyphArtifact, + expectedInputHash: "hash-1", + }); + const generatedRude = generatedResult.matches[0]?.matches.find( + (entry) => entry.familyName === "Rude Slab Condensed", + ); + expect(generatedRude?.visualDiagnostics?.aggregateVisualAffinity).toBe(94); }); it("falls back to metadata-only scoring when no glyph artifact is available", () => { @@ -367,4 +408,17 @@ describe("brand font matching scoring", () => { expect(ranked[0]?.scoreBreakdown.profile).toBe("metadata-only"); expect(ranked[0]?.scoreBreakdown.visualAffinity).toBe(0); }); + + it("reports explicit null glyph artifacts as missing during live reranks", () => { + const result = buildBrandFontMatchResults([], [], [], { + glyphComparisonArtifact: null, + expectedInputHash: "hash-1", + }); + + expect(result.visualEvidenceHealth).toMatchObject({ + status: "missing", + reason: "missing-artifact", + compatible: false, + }); + }); }); diff --git a/apps/web/tests/cast-socialblade-snapshot-wiring.test.ts b/apps/web/tests/cast-socialblade-snapshot-wiring.test.ts index 77f67afd..8e1809ca 100644 --- a/apps/web/tests/cast-socialblade-snapshot-wiring.test.ts +++ b/apps/web/tests/cast-socialblade-snapshot-wiring.test.ts @@ -13,4 +13,40 @@ describe("cast socialblade snapshot wiring", () => { expect(contents).toContain("useSharedPollingResource"); expect(contents).toContain("/api/admin/trr-api/social-growth/cast-comparison/snapshot"); }); + + it("tracks queued Modal refreshes by call id without failing slow jobs early", () => { + const filePath = path.resolve( + __dirname, + "../src/components/admin/cast-socialblade-comparison.tsx", + ); + const contents = readFileSync(filePath, "utf8"); + + expect(contents).toContain("type PendingRefreshState"); + expect(contents).toContain("callId"); + expect(contents).toContain("Queued in Modal"); + expect(contents).toContain("Still running in Modal"); + expect(contents).toContain("/api/admin/trr-api/social-growth/calls/"); + expect(contents).toContain("/api/admin/trr-api/social-growth/history"); + expect(contents).toContain("Job History"); + expect(contents).toContain("formatTimingDetail"); + expect(contents).toContain("setPendingRefreshState"); + expect(contents).toContain("status: \"still_running\""); + expect(contents).toContain("lastRefreshSnapshotEventMsRef"); + }); + + it("keeps the mocked e2e harness aligned with season social cast-comparison flows", () => { + const fixturesPath = path.resolve(__dirname, "./e2e/admin-fixtures.ts"); + const specPath = path.resolve(__dirname, "./e2e/admin-cast-tabs-smoke.spec.ts"); + const fixtures = readFileSync(fixturesPath, "utf8"); + const spec = readFileSync(specPath, "utf8"); + + expect(fixtures).toContain("gotoSeasonSocialCastContent"); + expect(fixtures).toContain("/api/admin/trr-api/social-growth/refresh-batch"); + expect(fixtures).toContain("/api/admin/trr-api/social-growth/cast-comparison/snapshot"); + expect(fixtures).toContain("/api/admin/trr-api/social-growth/history"); + expect(fixtures).toContain("calls|call-status"); + expect(spec).toContain("SOCIAL BLADE"); + expect(spec).toContain("Queued in Modal"); + expect(spec).toContain("Running in Modal"); + }); }); diff --git a/apps/web/tests/covered-shows-page-no-fanout-wiring.test.ts b/apps/web/tests/covered-shows-page-no-fanout-wiring.test.ts index 4b2f2116..ee1a5416 100644 --- a/apps/web/tests/covered-shows-page-no-fanout-wiring.test.ts +++ b/apps/web/tests/covered-shows-page-no-fanout-wiring.test.ts @@ -12,6 +12,14 @@ describe("covered shows page enriched payload wiring", () => { expect(contents).toMatch(/show\.poster_url/); }); + it("starts all show refresh targets when adding or opening a show", () => { + expect(contents).toMatch(/const AUTO_SHOW_REFRESH_TARGETS = \["show_core", "links", "bravo", "cast_profiles", "cast_media"\] as const;/); + expect(contents).toMatch(/targets: AUTO_SHOW_REFRESH_TARGETS/); + expect(contents).toMatch(/source: "admin_show_list_add"/); + expect(contents).toMatch(/enqueueShowAutoRefresh\(showId, "admin_show_list_pre_navigation"\)/); + expect(contents).toMatch(/showRefreshStarted=1/); + }); + it("does not fan out to per-show seasons/show endpoints for covered card metadata", () => { expect(contents).not.toMatch(/\/api\/admin\/trr-api\/shows\/\$\{trrShowId\}\/seasons\?limit=50/); expect(contents).not.toMatch(/\/api\/admin\/trr-api\/shows\/\$\{trrShowId\}/); diff --git a/apps/web/tests/dev-dashboard-vercel-preview-readiness-service.test.ts b/apps/web/tests/dev-dashboard-vercel-preview-readiness-service.test.ts new file mode 100644 index 00000000..aae4af0e --- /dev/null +++ b/apps/web/tests/dev-dashboard-vercel-preview-readiness-service.test.ts @@ -0,0 +1,116 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readVercelCleanupDoctor, readVercelPreviewReadinessArtifact } from "@/lib/server/admin/dev-dashboard-service"; + +describe("readVercelPreviewReadinessArtifact", () => { + let workspaceRoot: string; + + beforeEach(async () => { + workspaceRoot = await mkdtemp(join(tmpdir(), "trr-vercel-preview-ready-")); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it("returns null when no latest preview readiness artifact exists", async () => { + await expect(readVercelPreviewReadinessArtifact(workspaceRoot)).resolves.toBeNull(); + }); + + it("parses enabled observability checks from the latest artifact", async () => { + const artifactDir = join(workspaceRoot, ".logs", "workspace", "vercel-preview-ready"); + await mkdir(artifactDir, { recursive: true }); + await writeFile( + join(artifactDir, "latest.json"), + JSON.stringify( + { + generatedAt: "2026-06-23T19:14:16.000Z", + projectName: "trr-app", + teamSlug: "the-reality-reports-projects", + teamId: "team_7H0rrcso8BtSY2Npuf2cWWaJ", + activeProjectDir: "/Users/thomashulihan/Projects/TRR/TRR-APP", + latestDeploymentUrl: "https://trr-example-the-reality-reports-projects.vercel.app", + checks: { + webAnalytics: { + status: 0, + stdout: 'Vercel CLI 48.10.0\n{"enabled":true}', + stderr: "", + }, + speedInsights: { + status: 0, + stdout: 'Vercel CLI 48.10.0\n{"enabled":true}', + stderr: "", + }, + deployments: { + status: 0, + stdout: + 'Vercel CLI 48.10.0\n{"deployments":[{"url":"trr-example-the-reality-reports-projects.vercel.app","state":"READY"}]}', + stderr: "", + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const readiness = await readVercelPreviewReadinessArtifact(workspaceRoot); + + expect(readiness).toMatchObject({ + projectName: "trr-app", + teamSlug: "the-reality-reports-projects", + latestDeploymentUrl: "https://trr-example-the-reality-reports-projects.vercel.app", + webAnalyticsEnabled: true, + speedInsightsEnabled: true, + errors: [], + }); + expect(readiness?.artifactPath).toBe(join(artifactDir, "latest.json")); + }); +}); + +describe("readVercelCleanupDoctor", () => { + let workspaceRoot: string; + + beforeEach(async () => { + workspaceRoot = await mkdtemp(join(tmpdir(), "trr-vercel-cleanup-doctor-")); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + async function writeProjectLink(projectDir: string, projectName: string, projectId: string) { + const vercelDir = join(projectDir, ".vercel"); + await mkdir(vercelDir, { recursive: true }); + await writeFile( + join(vercelDir, "project.json"), + JSON.stringify( + { + projectName, + projectId, + orgId: "team_test", + }, + null, + 2, + ), + "utf-8", + ); + } + + it("reports stale nested web project links", async () => { + await writeProjectLink(workspaceRoot, "trr-app", "prj_MHpStkwr26rV5kjt0f80zqhwZpAs"); + await writeProjectLink(join(workspaceRoot, "apps", "web"), "web", "prj_0nWn8xpm9ikhcvhzE3ma4jUXTe1p"); + + const doctor = await readVercelCleanupDoctor(workspaceRoot); + + expect(doctor.ok).toBe(false); + expect(doctor.links.map((link) => link.classification)).toEqual([ + "project-of-record", + "stale-old-web-project", + ]); + expect(doctor.links[1]?.cleanupPath).toBe(join(workspaceRoot, "apps", "web", ".vercel")); + }); +}); diff --git a/apps/web/tests/e2e/admin-cast-tabs-smoke.spec.ts b/apps/web/tests/e2e/admin-cast-tabs-smoke.spec.ts index 3e7b292a..4bb12ee2 100644 --- a/apps/web/tests/e2e/admin-cast-tabs-smoke.spec.ts +++ b/apps/web/tests/e2e/admin-cast-tabs-smoke.spec.ts @@ -7,6 +7,7 @@ import { buildCastRoleMember, buildSeasonCastMember, buildShowCastMember, + gotoSeasonSocialCastContent, mockAdminApi, waitForAdminReady, } from "./admin-fixtures"; @@ -54,6 +55,16 @@ test.describe("cast + season tabs smoke (mocked)", () => { return cancelButton; }; + const recoverCastContentAuthRace = async (page: Page) => { + const retryButton = page.getByRole("button", { name: "Retry" }); + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!(await retryButton.isVisible().catch(() => false))) return; + await page.waitForTimeout((attempt + 1) * 750); + await retryButton.click(); + if (!(await retryButton.isVisible().catch(() => false))) return; + } + }; + test("show deep-link role editor waits for cast intelligence before consuming URL params", async ({ page }) => { await mockAdminApi(page, { showCastMembers: castRows, @@ -172,4 +183,28 @@ test.describe("cast + season tabs smoke (mocked)", () => { page.getByText(/1\/1\/2 cast · 0\/0\/0 crew · 1\/1\/2 visible|1\/2\/2 cast · 0\/0\/0 crew · 1\/2\/2 visible/).first() ).toBeVisible({ timeout: 8_000 }); }); + + test("season social cast comparison queues SocialBlade refreshes with queued and running call ids", async ({ page }) => { + await mockAdminApi(page, { + castRoleMembers: roleMembers, + socialGrowthRefreshCallIdsByKey: { + [`${CAST_PERSON_PRIMARY_ID}:lisabarlow`]: "modal-lisa-001", + [`${CAST_PERSON_SECONDARY_ID}:heathergay`]: "modal-heat-001", + }, + socialGrowthRefreshPollsBeforeData: 3, + }); + + await gotoSeasonSocialCastContent(page); + await recoverCastContentAuthRace(page); + + await expect(page.getByRole("button", { name: "SOCIAL BLADE" })).toBeVisible({ timeout: 20_000 }); + await page.getByRole("button", { name: "SOCIAL BLADE" }).click(); + await expect(page.getByText("No SocialBlade data available")).toBeVisible({ timeout: 20_000 }); + + await page.getByRole("button", { name: "Refresh SocialBlade" }).click(); + + await expect(page.getByText("Queued in Modal").first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(/modal-lisa|modal-heat/).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("Running in Modal").first()).toBeVisible({ timeout: 12_000 }); + }); }); diff --git a/apps/web/tests/e2e/admin-fixtures.ts b/apps/web/tests/e2e/admin-fixtures.ts index aa231901..4c5c584d 100644 --- a/apps/web/tests/e2e/admin-fixtures.ts +++ b/apps/web/tests/e2e/admin-fixtures.ts @@ -9,6 +9,7 @@ export const CAST_PERSON_PRIMARY_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; export const CAST_PERSON_SECONDARY_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const ADMIN_LOADING_MARKERS = ["Loading admin access", "Preparing admin dashboard", "Checking admin access"]; +const SEASON_SOCIAL_ROUTE_RE = /\/s\d+\/social\/cast-comparison(?:\?|$)/; const json = async (route: Route, body: unknown, status = 200) => { await route.fulfill({ @@ -43,6 +44,9 @@ export type MockAdminApiOptions = { seasonCastMembers?: unknown[]; castRoleMembers?: unknown[]; showRoles?: unknown[]; + socialGrowthByKey?: Record; + socialGrowthRefreshCallIdsByKey?: Record; + socialGrowthRefreshPollsBeforeData?: number; castRoleMembersDelayMs?: number; castRoleMembersStatus?: number; showRolesStatus?: number; @@ -97,10 +101,97 @@ export const buildCastRoleMember = ( seasons_appeared: 1, latest_season: SEASON_NUMBER, roles, + display_name: name, + instagram_handle: name.toLowerCase().replace(/[^a-z0-9]+/g, ""), + instagram_handle_source: "mock", photo_url: "https://example.com/photo.jpg", ...overrides, }); +const buildSocialGrowthData = ( + handle: string, + overrides: Partial> = {} +) => ({ + username: handle, + account_handle: handle, + platform: "instagram", + scraped_at: "2026-06-19T12:00:00.000Z", + freshness_status: "fresh", + is_stale: false, + age_hours: 0.25, + socialblade_url: `https://socialblade.com/instagram/user/${handle}`, + chart_metric_label: "Followers", + profile_stats: { + followers: 210_000, + following: 1_800, + media_count: 950, + engagement_rate: "3.20%", + average_likes: 12_400, + average_comments: 280, + }, + rankings: { + sb_rank: "#12,345", + followers_rank: "#8,901", + engagement_rate_rank: "#2,468", + grade: "A-", + }, + daily_channel_metrics_60day: { + period: "60d", + row_count: 2, + headers: ["date", "followers"], + data: [ + { date: "2026-06-17", followers: "209400" }, + { date: "2026-06-18", followers: "210000" }, + ], + }, + daily_total_followers_chart: { + frequency: "daily", + metric: "followers", + total_data_points: 2, + date_range: { from: "2026-06-17", to: "2026-06-18" }, + data: [ + { date: "2026-06-17", followers: 209_400 }, + { date: "2026-06-18", followers: 210_000 }, + ], + }, + ...overrides, +}); + +const buildSeasonSocialSnapshot = () => ({ + data: { + analytics: null, + targets: [], + runs: [], + run_summaries: [], + jobs: [], + worker_health: { + queue_enabled: true, + healthy: true, + healthy_workers: 1, + reason: null, + checked_at: "2026-06-19T12:00:00.000Z", + }, + shared_status: { + live_status: "idle", + sync_status: "idle", + ingest_status: "idle", + }, + generated_at: "2026-06-19T12:00:00.000Z", + cache_age_ms: 0, + stale: false, + }, + generated_at: "2026-06-19T12:00:00.000Z", + cache_age_ms: 0, + stale: false, +}); + +type PendingSocialGrowthRefresh = { + callId: string; + pollsRemaining: number; + history: Array>; + data: Record; +}; + export async function mockAdminApi(page: Page, options: MockAdminApiOptions = {}) { const showSeasonsPathRe = /^\/api\/admin\/trr-api\/shows\/[^/]+\/seasons$/; const showCastPathRe = /^\/api\/admin\/trr-api\/shows\/[^/]+\/cast$/; @@ -113,6 +204,17 @@ export async function mockAdminApi(page: Page, options: MockAdminApiOptions = {} const seasonAssetsPathRe = /^\/api\/admin\/trr-api\/shows\/[^/]+\/seasons\/[^/]+\/assets$/; const showNewsPathRe = /^\/api\/admin\/trr-api\/shows\/[^/]+\/news$/; const showBravoVideosPathRe = /^\/api\/admin\/trr-api\/shows\/[^/]+\/bravo\/videos$/; + const seasonSocialSnapshotPathRe = + /^\/api\/admin\/trr-api\/shows\/[^/]+\/seasons\/[^/]+\/social\/analytics\/snapshot$/; + + const socialGrowthByKey = new Map>( + Object.entries(options.socialGrowthByKey ?? {}).flatMap(([key, value]) => + value && typeof value === "object" ? [[key, value as Record]] : [] + ) + ); + const pendingSocialGrowthByKey = new Map(); + const socialGrowthHistoryByKey = new Map>>(); + const socialGrowthRefreshPollsBeforeData = Math.max(1, options.socialGrowthRefreshPollsBeforeData ?? 1); await page.route("**/api/admin/**", async (route) => { const requestUrl = new URL(route.request().url()); @@ -211,6 +313,10 @@ export async function mockAdminApi(page: Page, options: MockAdminApiOptions = {} return json(route, options.castRoleMembers ?? []); } + if (seasonSocialSnapshotPathRe.test(path)) { + return json(route, buildSeasonSocialSnapshot()); + } + if (showRefreshStreamPathRe.test(path)) { await sleep(options.showRefreshStreamDelayMs ?? 0); return fulfillSse( @@ -316,6 +422,128 @@ export async function mockAdminApi(page: Page, options: MockAdminApiOptions = {} }); } + if (/^\/api\/admin\/trr-api\/people\/[^/]+\/social-growth$/.test(path)) { + const personId = path.split("/")[5] ?? ""; + const handle = String(requestUrl.searchParams.get("handle") ?? "").trim(); + const key = `${personId}:${handle}`; + const pending = pendingSocialGrowthByKey.get(key); + const data = socialGrowthByKey.get(key) ?? (pending?.pollsRemaining === 0 ? pending.data : null); + if (!data) { + return json(route, { error: "No SocialBlade data found" }, 404); + } + return json(route, data); + } + + if (path === "/api/admin/trr-api/social-growth/cast-comparison/snapshot") { + const snapshot = buildSeasonSocialSnapshot(); + const items = requestUrl.searchParams.getAll("item").map((rawItem) => { + const [personId, ...handleParts] = rawItem.split(":"); + const handle = handleParts.join(":").trim(); + const key = `${personId.trim()}:${handle}`; + const pending = pendingSocialGrowthByKey.get(key); + if (pending) { + const nextPollsRemaining = Math.max(0, pending.pollsRemaining - 1); + pendingSocialGrowthByKey.set(key, { ...pending, pollsRemaining: nextPollsRemaining }); + if (nextPollsRemaining === 0) { + socialGrowthByKey.set(key, pending.data); + socialGrowthHistoryByKey.set(key, pending.history); + } + } + + const currentPending = pendingSocialGrowthByKey.get(key); + const data = + socialGrowthByKey.get(key) ?? (currentPending && currentPending.pollsRemaining === 0 ? currentPending.data : null); + return { + personId: personId.trim(), + handle, + data, + error: data ? null : "No SocialBlade data found", + not_found: !data, + }; + }); + return json(route, { + ...snapshot, + data: { + ...snapshot.data, + items, + }, + }); + } + + if (path === "/api/admin/trr-api/social-growth/refresh-batch" && route.request().method() === "POST") { + const body = JSON.parse(route.request().postData() ?? "{}") as { + items?: Array<{ personId?: string; handle?: string }>; + }; + const accepted = Array.isArray(body.items) + ? body.items.flatMap((item, index) => { + const personId = String(item.personId ?? "").trim(); + const handle = String(item.handle ?? "").trim(); + if (!personId || !handle) return []; + const key = `${personId}:${handle}`; + const callId = + options.socialGrowthRefreshCallIdsByKey?.[key] ?? + `modal-${handle.slice(0, 4).padEnd(4, "x")}-${index + 1}`; + const data = + socialGrowthByKey.get(key) ?? + buildSocialGrowthData(handle, { + refresh_status: "refreshed", + stats_refreshed: true, + }); + const history = [ + { + call_id: callId, + status: "completed", + source: "cast_comparison", + handle, + person_id: personId, + scraped_at: "2026-06-19T12:00:00.000Z", + }, + ]; + pendingSocialGrowthByKey.set(key, { + callId, + pollsRemaining: socialGrowthRefreshPollsBeforeData, + history, + data, + }); + return [{ personId, handle, callId }]; + }) + : []; + return json(route, { accepted, skipped: [], errors: [] }, 202); + } + + if ( + /^\/api\/admin\/trr-api\/social-growth\/(?:calls|call-status)\/[^/]+(?:\/status)?$/.test(path) + ) { + const callId = path.match(/\/(?:calls|call-status)\/([^/]+)(?:\/status)?$/)?.[1] ?? ""; + const pendingEntry = [...pendingSocialGrowthByKey.values()].find((entry) => entry.callId === callId); + const completedEntry = [...socialGrowthHistoryByKey.entries()].find(([, history]) => + history.some((item) => item.call_id === callId) + ); + if (pendingEntry) { + const status = pendingEntry.pollsRemaining >= socialGrowthRefreshPollsBeforeData ? "queued" : "running"; + return json(route, { call_id: callId, status }); + } + if (completedEntry) { + return json(route, { call_id: callId, status: "completed" }); + } + return json(route, { call_id: callId, status: "not_found" }, 404); + } + + if ( + path === "/api/admin/trr-api/social-growth/history" || + /^\/api\/admin\/trr-api\/people\/[^/]+\/social-growth\/history$/.test(path) + ) { + const personId = + /^\/api\/admin\/trr-api\/people\/[^/]+\/social-growth\/history$/.test(path) + ? path.split("/")[5] ?? "" + : String(requestUrl.searchParams.get("personId") ?? "").trim(); + const handle = String(requestUrl.searchParams.get("handle") ?? "").trim(); + const key = `${personId}:${handle}`; + return json(route, { + items: socialGrowthHistoryByKey.get(key) ?? [], + }); + } + if (path === `/api/admin/covered-shows/${SHOW_ID}`) { return json(route, { show_id: SHOW_ID }, 404); } @@ -346,3 +574,21 @@ export async function waitForAdminReady(page: Page, timeoutMs = 90_000) { ) .toBe(false); } + +export async function gotoSeasonSocialCastContent(page: Page) { + const legacyRoute = + `/admin/trr-shows/${SHOW_ID}/seasons/${SEASON_NUMBER}?tab=social&social_view=cast-content`; + const canonicalRoute = `/${SHOW_SLUG}/s${SEASON_NUMBER}/social/cast-comparison`; + + await page.goto(legacyRoute); + await waitForAdminReady(page); + + if (!SEASON_SOCIAL_ROUTE_RE.test(page.url())) { + await page.goto(canonicalRoute); + await waitForAdminReady(page); + } + + await expect(page.getByRole("tab", { name: "Social" })).toHaveAttribute("aria-selected", "true"); + await expect(page.getByText("Cast Comparison").first()).toBeVisible({ timeout: 20_000 }); + await expect(page).toHaveURL(SEASON_SOCIAL_ROUTE_RE, { timeout: 20_000 }); +} diff --git a/apps/web/tests/finish.flow.test.tsx b/apps/web/tests/finish.flow.test.tsx index 58df07b9..a507e9da 100644 --- a/apps/web/tests/finish.flow.test.tsx +++ b/apps/web/tests/finish.flow.test.tsx @@ -29,6 +29,12 @@ vi.mock('@/lib/db/users', () => ({ import FinishPage from '@/app/auth/finish/page'; import { ALL_SHOWS } from '@/lib/data/shows'; +const REQUESTED_SHOWS = [ + 'Summer House', + 'The Real Housewives of Rhode Island', + 'Love Island USA', +]; + describe('/auth/finish interactions', () => { beforeEach(() => { sessionStorage.clear(); @@ -63,6 +69,31 @@ describe('/auth/finish interactions', () => { expectedLogs.expectCalled(); }); + it('renders API-provided covered shows in the picker list', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + shows: REQUESTED_SHOWS.map((name) => ({ id: name, name, alternativeNames: [] })), + }), + } as Response)); + + render(); + + for (const show of REQUESTED_SHOWS) { + expect(await screen.findByRole('button', { name: show })).toBeInTheDocument(); + } + }); + + it('falls back to the local show list when the API is unavailable', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('shows unavailable'))); + + render(); + + expect(await screen.findByRole('button', { name: ALL_SHOWS[0] })).toBeInTheDocument(); + consoleError.mockRestore(); + }); + it('captures show requests from the request CTA', async () => { const expectedLogs = captureExpectedConsoleLog(/^Finish: /); render(); diff --git a/apps/web/tests/instagram-catalog-backfill-mockup-page.test.tsx b/apps/web/tests/instagram-catalog-backfill-mockup-page.test.tsx new file mode 100644 index 00000000..eae19d46 --- /dev/null +++ b/apps/web/tests/instagram-catalog-backfill-mockup-page.test.tsx @@ -0,0 +1,372 @@ +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import InstagramCatalogBackfillMockupPage from "@/app/admin/dev-dashboard/instagram-catalog-backfill-mockup/page"; + +const mocks = vi.hoisted(() => ({ + fetchAdminWithAuth: vi.fn(), + useAdminGuard: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + children, + href, + prefetch, + ...props + }: React.AnchorHTMLAttributes & { href: string; prefetch?: boolean }) => { + void prefetch; + return ( + + {children} + + ); + }, +})); + +vi.mock("@/lib/admin/client-auth", () => ({ + fetchAdminWithAuth: (...args: unknown[]) => (mocks.fetchAdminWithAuth as (...inner: unknown[]) => unknown)(...args), +})); + +vi.mock("@/lib/admin/useAdminGuard", () => ({ + useAdminGuard: (...args: unknown[]) => (mocks.useAdminGuard as (...inner: unknown[]) => unknown)(...args), +})); + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + +describe("InstagramCatalogBackfillMockupPage", () => { + beforeEach(() => { + mocks.fetchAdminWithAuth.mockReset(); + mocks.useAdminGuard.mockReset(); + mocks.useAdminGuard.mockReturnValue({ + user: { uid: "admin-1" }, + checking: false, + hasAccess: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders the current-truth catalog backfill status from the active run", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/catalog/runs/recent?limit=8")) { + return jsonResponse({ + platform: "instagram", + handle: "bravotv", + catalog_recent_runs: [ + { + job_id: "mock-job-1", + run_id: "mock-run-1", + status: "running", + created_at: "2026-07-01T14:00:00Z", + }, + ], + }); + } + if (url.includes("/catalog/runs/mock-run-1/progress")) { + expect(url).toContain("fast=1"); + return jsonResponse({ + run_id: "mock-run-1", + run_status: "running", + instagram_posts_auth_mode: "authenticated", + post_progress: { + completed_posts: 74, + total_posts: 17574, + }, + detail_worker_count: 2, + attached_followups: { + comments: { + state: "pending", + status: "pending", + source: "deferred_after_catalog", + }, + }, + summary: { + total_jobs: 2, + completed_jobs: 0, + failed_jobs: 0, + active_jobs: 2, + items_found_total: 74, + }, + }); + } + if (url.includes("/completion-summary?year=")) { + return jsonResponse({ + year: 2026, + total_posts: 17574, + total_reported_comments: 2299198, + saved_comments: 1118683, + missing_comments: 1180515, + lanes: { + comments: { finished: 6000, in_progress: 400, not_started: 11174 }, + details: { finished: 74, in_progress: 0, not_started: 17500 }, + media: { finished: 16691, in_progress: 200, not_started: 683 }, + }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + expect(await screen.findByText("Instagram Backfill Status")).toBeInTheDocument(); + expect(screen.getByText("Current Truth")).toBeInTheDocument(); + expect(screen.getByText("Mockup")).toBeInTheDocument(); + expect(screen.getByText(/74 \/ 17,574 posts checked/)).toBeInTheDocument(); + expect(screen.getByText(/2 workers active/)).toBeInTheDocument(); + expect(screen.getByText("No blocker")).toBeInTheDocument(); + await waitFor(() => { + expect(mocks.fetchAdminWithAuth.mock.calls.some(([input]) => String(input).includes("fast=1"))).toBe(true); + }); + }); + + it("loads attached comments progress for the shard health dashboard", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/catalog/runs/recent?limit=8")) { + return jsonResponse({ + platform: "instagram", + handle: "bravotv", + catalog_recent_runs: [ + { + job_id: "mock-job-1", + run_id: "mock-run-1", + comments_run_id: "comments-run-1", + status: "running", + created_at: "2026-07-01T14:00:00Z", + }, + ], + }); + } + if (url.includes("/catalog/runs/mock-run-1/progress")) { + return jsonResponse({ + run_id: "mock-run-1", + run_status: "running", + comments_run_id: "comments-run-1", + post_progress: { + completed_posts: 74, + total_posts: 17574, + }, + summary: { + total_jobs: 2, + active_jobs: 2, + }, + }); + } + if (url.includes("/comments/runs/comments-run-1/progress")) { + return jsonResponse({ + run_id: "comments-run-1", + platform: "instagram", + account_handle: "bravotv", + run_status: "running", + post_progress: { + completed_posts: 12, + total_posts: 60, + }, + summary: { + comments_processed_total: 4096, + }, + throughput: { + posts_per_minute: 1.5, + comments_per_minute: 320.4, + }, + worker_counters: { + running: 1, + retrying: 1, + queued: 2, + failed: 0, + total: 4, + }, + shards: [ + { + shard_index: 0, + shard_count: 4, + job_id: "comment-job-1", + status: "running", + completed_posts: 12, + target_count: 15, + comments_processed: 4096, + comments_per_minute: 320.4, + }, + ], + }); + } + if (url.includes("/completion-summary?year=")) { + return jsonResponse({ + year: 2026, + total_posts: 17574, + total_reported_comments: 2299198, + saved_comments: 1118683, + missing_comments: 1180515, + lanes: { + comments: { finished: 6000, in_progress: 400, not_started: 11174 }, + details: { finished: 74, in_progress: 0, not_started: 17500 }, + media: { finished: 16691, in_progress: 200, not_started: 683 }, + }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + expect(await screen.findByText("Shard Health")).toBeInTheDocument(); + expect((await screen.findAllByText(/Run comments/)).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/Shard 1 of 4/)).toBeInTheDocument(); + expect(screen.getByText(/4,096 fetched/)).toBeInTheDocument(); + await waitFor(() => { + expect( + mocks.fetchAdminWithAuth.mock.calls.some(([input]) => + String(input).includes("/comments/runs/comments-run-1/progress"), + ), + ).toBe(true); + }); + }); + + it("dedupes mockup history and excludes the displayed active run", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/catalog/runs/recent?limit=8")) { + return jsonResponse({ + platform: "instagram", + handle: "bravotv", + catalog_recent_runs: [ + { + job_id: "active-job-1", + run_id: "active-run-1", + status: "running", + created_at: "2026-07-01T14:00:00Z", + }, + { + job_id: "active-job-1-duplicate", + run_id: "active-run-1", + status: "running", + created_at: "2026-07-01T14:00:00Z", + }, + { + job_id: "older-job-1", + run_id: "older-run-1", + status: "cancelled", + created_at: "2026-06-30T14:00:00Z", + }, + { + job_id: "older-job-1-duplicate", + run_id: "older-run-1", + status: "cancelled", + created_at: "2026-06-30T14:00:00Z", + }, + ], + }); + } + if (url.includes("/catalog/runs/active-run-1/progress")) { + return jsonResponse({ + run_id: "active-run-1", + run_status: "running", + post_progress: { + completed_posts: 74, + total_posts: 17574, + }, + summary: { + total_jobs: 2, + completed_jobs: 0, + failed_jobs: 0, + active_jobs: 2, + items_found_total: 74, + }, + }); + } + if (url.includes("/completion-summary?year=")) { + return jsonResponse({ + year: 2026, + total_posts: 17574, + total_reported_comments: 2299198, + saved_comments: 1118683, + missing_comments: 1180515, + lanes: { + comments: { finished: 6000, in_progress: 400, not_started: 11174 }, + details: { finished: 74, in_progress: 0, not_started: 17500 }, + media: { finished: 16691, in_progress: 200, not_started: 683 }, + }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + expect( + await screen.findByText((content) => content.includes("Run active-r") && content.includes("Running")), + ).toBeInTheDocument(); + const historyHeading = screen.getByText("History"); + const historyCard = historyHeading.closest('[data-slot="card"]'); + expect(historyCard).not.toBeNull(); + const history = within(historyCard as HTMLElement); + expect(history.queryByText((content) => content.includes("Run active-r"))).not.toBeInTheDocument(); + expect(history.getAllByText((content) => content.includes("Run older-ru"))).toHaveLength(1); + }); + + it("mutes active-looking lane states when the parent run has failed", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/catalog/runs/recent?limit=8")) { + return jsonResponse({ + platform: "instagram", + handle: "bravotv", + catalog_recent_runs: [ + { + job_id: "failed-job-1", + run_id: "failed-run-1", + status: "failed", + error_message: "modal invocation failed", + created_at: "2026-07-01T14:00:00Z", + }, + ], + }); + } + if (url.includes("/catalog/runs/failed-run-1/progress")) { + return jsonResponse({ + run_id: "failed-run-1", + run_status: "failed", + details_progress: { status: "queued" }, + attached_followups: { + comments: { status: "pending", state: "pending", source: "deferred_after_catalog" }, + media: { status: "pending", state: "pending", source: "catalog_media_mirror", enqueued_job_count: 0 }, + }, + summary: { + total_jobs: 6, + active_jobs: 0, + }, + }); + } + if (url.includes("/completion-summary?year=")) { + return jsonResponse({ + year: 2026, + total_posts: 17574, + total_reported_comments: 0, + saved_comments: 0, + missing_comments: 0, + lanes: { + comments: { finished: 0, in_progress: 0, not_started: 0 }, + details: { finished: 0, in_progress: 0, not_started: 0 }, + media: { finished: 0, in_progress: 0, not_started: 0 }, + }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + expect(await screen.findByText("Run failed")).toBeInTheDocument(); + expect(screen.getByText("modal invocation failed")).toBeInTheDocument(); + expect(screen.getAllByText("Failed").length).toBeGreaterThanOrEqual(3); + expect(screen.getAllByText("Parent run is failed; no lane is currently running.").length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/apps/web/tests/postgres-connection-string-resolution.test.ts b/apps/web/tests/postgres-connection-string-resolution.test.ts index 453de6d9..7059a969 100644 --- a/apps/web/tests/postgres-connection-string-resolution.test.ts +++ b/apps/web/tests/postgres-connection-string-resolution.test.ts @@ -12,6 +12,7 @@ import { resolvePostgresApplicationName, resolvePostgresPoolSizing, resolvePostgresConnectionString, + resolvePgPoolConnectionString, resolvePostgresSslConfig, resolveRuntimeConnectionLane, shouldAttachPostgresPoolToVercel, @@ -157,6 +158,16 @@ describe("resolvePostgresSslConfig", () => { expect(config).toEqual({ rejectUnauthorized: false }); }); + it("strips pg ssl query keys so the explicit ssl object is not overwritten", () => { + const value = resolvePgPoolConnectionString( + "postgresql://postgres.ref:secret@aws-1-us-east-1.pooler.supabase.com:5432/postgres?sslmode=require&application_name=trr-app&sslrootcert=/tmp/root.crt", + ); + + expect(value).toBe( + "postgresql://postgres.ref:secret@aws-1-us-east-1.pooler.supabase.com:5432/postgres?application_name=trr-app", + ); + }); + it("loads ssl CA files from the canonical env name", () => { const tempDir = mkdtempSync(join(tmpdir(), "trr-ca-")); try { diff --git a/apps/web/tests/prefixed-pathname.test.ts b/apps/web/tests/prefixed-pathname.test.ts index b6b4e918..8eb5d671 100644 --- a/apps/web/tests/prefixed-pathname.test.ts +++ b/apps/web/tests/prefixed-pathname.test.ts @@ -12,4 +12,9 @@ describe("extractPrefixedPathSegment", () => { it("returns undefined when the segment does not match the expected prefix", () => { expect(extractPrefixedPathSegment("/rhoslc/social/reddit/BravoRealHousewives", 1, "s")).toBeUndefined(); }); + + it("rejects numeric-prefixed helper tokens with extra path text", () => { + expect(extractPrefixedPathSegment("/rhoslc/s6abc/social/w01", 1, "s")).toBeUndefined(); + expect(extractPrefixedPathSegment("/rhoslc/s6/social/w01-extra", 3, "w")).toBeUndefined(); + }); }); diff --git a/apps/web/tests/public-route-boundary.test.ts b/apps/web/tests/public-route-boundary.test.ts index 4ccafae1..10c5bb73 100644 --- a/apps/web/tests/public-route-boundary.test.ts +++ b/apps/web/tests/public-route-boundary.test.ts @@ -30,6 +30,13 @@ const PUBLIC_ROUTE_FILES = [ "src/app/[showId]/s[seasonNumber]/social/reddit/[communitySlug]/[windowKey]/post/[postId]/page.tsx", ]; +const LEGACY_ADMIN_REDDIT_SEASON_ROUTE_FILES = [ + "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/page.tsx", + "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/page.tsx", + "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/post/[postId]/page.tsx", + "src/app/admin/social/reddit/[communitySlug]/[showSlug]/s[seasonNumber]/[windowKey]/[detailSlug]/page.tsx", +]; + describe("public route boundary", () => { it.each(PUBLIC_ROUTE_FILES)( "keeps %s free of admin-only imports and guards", @@ -43,4 +50,17 @@ describe("public route boundary", () => { expect(source).not.toMatch(/redirect\((["'`])\/admin\//); }, ); + + it.each(LEGACY_ADMIN_REDDIT_SEASON_ROUTE_FILES)( + "keeps %s as a redirect shim instead of an admin page re-export", + (relativePath) => { + const filePath = path.resolve(process.cwd(), relativePath); + const source = fs.readFileSync(filePath, "utf8"); + + expect(source).toMatch(/from "next\/navigation"/); + expect(source).toMatch(/redirect\(/); + expect(source).not.toMatch(/export \{ default \} from "@\/app\/admin\//); + expect(source).not.toMatch(/export \{ default \} from "@\/components\/admin\//); + }, + ); }); diff --git a/apps/web/tests/shared-env-contract.test.ts b/apps/web/tests/shared-env-contract.test.ts index b7ef8eca..d07542c8 100644 --- a/apps/web/tests/shared-env-contract.test.ts +++ b/apps/web/tests/shared-env-contract.test.ts @@ -75,4 +75,14 @@ describe("workspace shared env contract", () => { expect(contract).toContain("TRR_REMOTE_DEBUG_LOG_ENABLED"); expect(contract).toContain("Hard kill switch for remote /api/debug-log writes"); }); + + it("projects admin host capabilities through explicit app env keys", () => { + const envExamplePath = path.join(process.cwd(), ".env.example"); + const envKeys = readEnvKeys(fs.readFileSync(envExamplePath, "utf-8")); + + expect(envKeys.has("ADMIN_APP_ORIGIN"), "missing admin origin projection").toBe(true); + expect(envKeys.has("ADMIN_APP_HOSTS"), "missing admin API host allowlist projection").toBe(true); + expect(envKeys.has("ADMIN_ENFORCE_HOST"), "missing admin host enforcement projection").toBe(true); + expect(envKeys.has("ADMIN_STRICT_HOST_ROUTING"), "missing strict host routing projection").toBe(true); + }); }); diff --git a/apps/web/tests/shared-live-resource-polling.test.tsx b/apps/web/tests/shared-live-resource-polling.test.tsx index 68a22e37..9c7ddd02 100644 --- a/apps/web/tests/shared-live-resource-polling.test.tsx +++ b/apps/web/tests/shared-live-resource-polling.test.tsx @@ -4,6 +4,7 @@ import { act, render } from "@testing-library/react"; import { __resetSharedLiveResourceRegistryForTests, + useSharedManualResource, useSharedPollingResource, } from "@/lib/admin/shared-live-resource"; @@ -32,14 +33,49 @@ const runPendingTimers = async (): Promise => { }); }; +const createDeferred = (): { promise: Promise; resolve: (value: T) => void } => { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +}; + +type PollRefresh = (request?: { + forceRefresh?: boolean; + cause?: "interval" | "manual" | "mutation" | "visibility"; +}) => void; + +type ManualSnapshot = ReturnType>; + +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + + readonly name: string; + onmessage: ((event: MessageEvent) => void) | null = null; + readonly postMessage = vi.fn(); + readonly close = vi.fn(); + + constructor(name: string) { + this.name = name; + MockBroadcastChannel.instances.push(this); + } + + receive(data: unknown): void { + this.onmessage?.({ data } as MessageEvent); + } +} + function PollingHarness({ fetcher, shouldRun, + onRefetch, }: { fetcher: (signal: AbortSignal) => Promise<{ ok: boolean }>; shouldRun: boolean; + onRefetch?: (refetch: PollRefresh) => void; }) { - useSharedPollingResource({ + const resource = useSharedPollingResource({ key: "shared-live-resource-hidden-tab-test", fetchData: fetcher, intervalMs: 1_000, @@ -48,9 +84,43 @@ function PollingHarness({ followerCheckIntervalMs: 1_000, startupJitterMs: [0, 0], }); + React.useEffect(() => { + onRefetch?.(resource.refetch); + }, [onRefetch, resource.refetch]); + return null; +} + +function ManualHarness({ + resourceKey, + shouldRun = false, + onSnapshot, +}: { + resourceKey: string; + shouldRun?: boolean; + onSnapshot: (snapshot: ManualSnapshot) => void; +}) { + const resource = useSharedManualResource<{ value: string }>({ + key: resourceKey, + shouldRun, + }); + React.useEffect(() => { + onSnapshot(resource); + }, [onSnapshot, resource]); return null; } +const snapshotStorageKey = (resourceKey: string): string => + `trr:shared-live:${resourceKey}:snapshot:v1`; + +const createSnapshot = (value: string, lastEventAtMs: number) => ({ + data: { value }, + error: null, + errorDetails: null, + lastEventAtMs, + lastSuccessAtMs: lastEventAtMs, + connected: true, +}); + describe("useSharedPollingResource visibility budget", () => { beforeEach(() => { vi.useFakeTimers(); @@ -61,12 +131,14 @@ describe("useSharedPollingResource visibility budget", () => { get: () => visibilityState, }); window.localStorage.clear(); + MockBroadcastChannel.instances = []; __resetSharedLiveResourceRegistryForTests(); }); afterEach(() => { __resetSharedLiveResourceRegistryForTests(); window.localStorage.clear(); + MockBroadcastChannel.instances = []; vi.useRealTimers(); }); @@ -92,4 +164,151 @@ describe("useSharedPollingResource visibility budget", () => { await flushTimers(1_000); expect(fetcher).toHaveBeenCalledTimes(2); }); + + it("does not start a duplicate poll when shared subscribers refresh during an in-flight request", async () => { + const firstRequest = createDeferred<{ ok: boolean }>(); + const queuedRequest = createDeferred<{ ok: boolean }>(); + const fetcher = vi + .fn<(signal: AbortSignal) => Promise<{ ok: boolean }>>() + .mockImplementationOnce(() => firstRequest.promise) + .mockImplementationOnce(() => queuedRequest.promise); + let firstRefetch: PollRefresh | null = null; + let secondRefetch: PollRefresh | null = null; + + render( + <> + { + firstRefetch = refetch; + }} + /> + { + secondRefetch = refetch; + }} + /> + , + ); + + await flushReact(); + await runPendingTimers(); + expect(fetcher).toHaveBeenCalledTimes(1); + + firstRefetch?.({ forceRefresh: true, cause: "manual" }); + secondRefetch?.({ forceRefresh: true, cause: "manual" }); + await runPendingTimers(); + expect(fetcher).toHaveBeenCalledTimes(1); + + await act(async () => { + firstRequest.resolve({ ok: true }); + await firstRequest.promise; + }); + await runPendingTimers(); + expect(fetcher).toHaveBeenCalledTimes(2); + + await act(async () => { + queuedRequest.resolve({ ok: true }); + await queuedRequest.promise; + }); + }); + + it("evicts the shared coordinator and closes its channel after the final subscriber unmounts", async () => { + vi.stubGlobal("BroadcastChannel", MockBroadcastChannel); + const onSnapshot = vi.fn(); + + const { rerender, unmount } = render( + <> + + + , + ); + await flushReact(); + + expect(MockBroadcastChannel.instances).toHaveLength(1); + const firstChannel = MockBroadcastChannel.instances[0]; + + rerender(); + await flushReact(); + expect(firstChannel.close).not.toHaveBeenCalled(); + + unmount(); + expect(firstChannel.close).toHaveBeenCalledTimes(1); + + render(); + await flushReact(); + expect(MockBroadcastChannel.instances).toHaveLength(2); + }); + + it("applies newer BroadcastChannel snapshots and ignores stale channel payloads", async () => { + vi.stubGlobal("BroadcastChannel", MockBroadcastChannel); + const snapshots: ManualSnapshot[] = []; + + render( + { + snapshots.push(snapshot); + }} + />, + ); + await flushReact(); + + const channel = MockBroadcastChannel.instances[0]; + await act(async () => { + channel.receive(createSnapshot("fresh", 2_000)); + }); + await flushReact(); + expect(snapshots.at(-1)?.data).toEqual({ value: "fresh" }); + expect(snapshots.at(-1)?.connected).toBe(true); + + await act(async () => { + channel.receive(createSnapshot("stale", 1_000)); + }); + await flushReact(); + expect(snapshots.at(-1)?.data).toEqual({ value: "fresh" }); + }); + + it("hydrates from storage and applies valid newer storage events", async () => { + const resourceKey = "shared-live-resource-storage-test"; + const snapshots: ManualSnapshot[] = []; + window.localStorage.setItem(snapshotStorageKey(resourceKey), JSON.stringify(createSnapshot("cached", 1_000))); + + render( + { + snapshots.push(snapshot); + }} + />, + ); + await flushReact(); + + expect(snapshots.at(-1)?.data).toEqual({ value: "cached" }); + + await act(async () => { + window.dispatchEvent( + new StorageEvent("storage", { + key: snapshotStorageKey(resourceKey), + newValue: "{malformed", + }), + ); + }); + await flushReact(); + expect(snapshots.at(-1)?.data).toEqual({ value: "cached" }); + + await act(async () => { + window.dispatchEvent( + new StorageEvent("storage", { + key: snapshotStorageKey(resourceKey), + newValue: JSON.stringify(createSnapshot("updated", 2_000)), + }), + ); + }); + await flushReact(); + expect(snapshots.at(-1)?.data).toEqual({ value: "updated" }); + }); }); diff --git a/apps/web/tests/show-admin-routes.test.ts b/apps/web/tests/show-admin-routes.test.ts index 1222e3fa..a9068230 100644 --- a/apps/web/tests/show-admin-routes.test.ts +++ b/apps/web/tests/show-admin-routes.test.ts @@ -602,8 +602,8 @@ describe("show-admin-routes", () => { postId: "1abcde", title: "The Real Housewives Of Salt Lake City - Season 6 - Episode 1 - Pre Episode Discussion", author: "AutoModerator", - }), - ).toBe( + }), + ).toBe( "/rhoslc/social/reddit/BravoRealHousewives/s6/e1/the-real-housewives-of-salt-lake-city-season-6-episode-1-pre-episode-discussion--u-automoderator", ); diff --git a/apps/web/tests/show-refresh-health-center-wiring.test.ts b/apps/web/tests/show-refresh-health-center-wiring.test.ts index 7d2aa3e1..3c6987aa 100644 --- a/apps/web/tests/show-refresh-health-center-wiring.test.ts +++ b/apps/web/tests/show-refresh-health-center-wiring.test.ts @@ -51,6 +51,16 @@ describe("show refresh health center wiring", () => { expect(showPage).toMatch(/gallery media refresh/); }); + it("auto-runs the full refresh once per viewed show", () => { + expect(showPage).toMatch(/searchParams\.get\("showRefreshOperationId"\) \?\? searchParams\.get\("showCoreOperationId"\)/); + expect(showPage).toMatch(/searchParams\.get\("showRefreshStarted"\) === "1"/); + expect(showPage).toMatch(/trr:show-full-auto-refresh:\$\{showId\}/); + expect(showPage).toMatch(/if \(preNavigationShowRefreshStarted\) return;/); + expect(showPage).toMatch(/if \(!autoShowCorePauseLoaded\) return;/); + expect(showPage).toMatch(/if \(autoShowCorePaused\) return;/); + expect(showPage).toMatch(/void refreshAllShowData\(\);/); + }); + it("removes duplicate gallery headings and stale inline progress bars from season assets", () => { expect(seasonPage).not.toMatch(/Season Images/); expect(seasonPage).not.toMatch(/Season Videos/); diff --git a/apps/web/tests/shows-route-config.test.ts b/apps/web/tests/shows-route-config.test.ts new file mode 100644 index 00000000..86c98abc --- /dev/null +++ b/apps/web/tests/shows-route-config.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import nextConfig from "../next.config"; + +type RouteRule = { + source: string; + destination: string; + permanent?: boolean; +}; + +type RewriteConfig = + | RouteRule[] + | { + beforeFiles?: RouteRule[]; + afterFiles?: RouteRule[]; + fallback?: RouteRule[]; + }; + +async function getRedirects(): Promise { + return nextConfig.redirects ? ((await nextConfig.redirects()) as RouteRule[]) : []; +} + +async function getBeforeFileRewrites(): Promise { + if (!nextConfig.rewrites) return []; + const rewrites = (await nextConfig.rewrites()) as RewriteConfig; + return Array.isArray(rewrites) ? rewrites : rewrites.beforeFiles ?? []; +} + +function routePatternToRegExp(source: string): RegExp { + const pattern = source + .split("/") + .filter(Boolean) + .map((segment) => { + if (segment.endsWith("*")) { + return "(?:/.*)?"; + } + const prefixedParam = segment.match(/^([a-z]+):[a-zA-Z0-9_]+(?:\((.*)\))?$/); + if (prefixedParam) { + return `/${prefixedParam[1]}${prefixedParam[2] ? `(${prefixedParam[2].replace(/\\\\/g, "\\")})` : "([^/]+)"}`; + } + if (segment.startsWith(":")) { + const paramPattern = segment.match(/^:[a-zA-Z0-9_]+(?:\((.*)\))?$/)?.[1]; + return `/${paramPattern ? `(${paramPattern.replace(/\\\\/g, "\\")})` : "([^/]+)"}`; + } + return `/${segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`; + }) + .join(""); + return new RegExp(`^${pattern || "/"}$`); +} + +describe("shows route config", () => { + it("does not redirect canonical /shows detail paths to root show paths", async () => { + const redirects = await getRedirects(); + + expect(redirects).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + source: expect.stringMatching(/^\/shows\/:showId/), + destination: expect.stringMatching(/^\/:showId/), + }), + ]), + ); + }); + + it.each([ + "/shows/rhoslc", + "/shows/rhoslc/social", + "/shows/rhoslc/s6/social", + "/shows/rhoslc/s6/social/week/0", + "/shows/rhoslc/s6/social/week/0/instagram", + ])("does not issue a static 307 redirect for %s", async (pathname) => { + const redirects = await getRedirects(); + const matchingRedirects = redirects.filter((rule) => routePatternToRegExp(rule.source).test(pathname)); + + expect(matchingRedirects).toEqual([]); + }); + + it("keeps /shows/* tab and season aliases as canonical before-file rewrites", async () => { + const rewrites = await getBeforeFileRewrites(); + + expect(rewrites).toEqual( + expect.arrayContaining([ + { source: "/shows/:showId/overview", destination: "/shows/:showId?tab=details" }, + { source: "/shows/:showId/settings", destination: "/shows/:showId?tab=settings" }, + { source: "/shows/:showId/social", destination: "/shows/:showId?tab=social&social_view=official" }, + { source: "/shows/:showId/s:seasonNumber", destination: "/shows/:showId/seasons/:seasonNumber" }, + { + source: "/shows/:showId/s:seasonNumber/social", + destination: "/shows/:showId/seasons/:seasonNumber?tab=social&social_view=official", + }, + { + source: "/shows/:showId/s:seasonNumber/social/week/:weekIndex/:platform", + destination: "/shows/:showId/seasons/:seasonNumber/social/week/:weekIndex/:platform", + }, + ]), + ); + }); +}); diff --git a/apps/web/tests/social-account-catalog-alt-page.test.ts b/apps/web/tests/social-account-catalog-alt-page.test.ts new file mode 100644 index 00000000..a1879eef --- /dev/null +++ b/apps/web/tests/social-account-catalog-alt-page.test.ts @@ -0,0 +1,58 @@ +// @ts-nocheck - server component route tests use mocked next/navigation behavior +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + notFound: () => { + throw new Error("NOT_FOUND"); + }, + redirect: (url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`); + }, +})); + +describe("social account catalog alt page", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("renders the Alt 1 catalog mockup for canonical params", async () => { + const page = await import("@/app/social/[platform]/[handle]/catalog/alt-1/page"); + const element = await page.default({ + params: Promise.resolve({ + platform: "instagram", + handle: "bravotv", + }), + }); + + expect(element.props.platform).toBe("instagram"); + expect(element.props.handle).toBe("bravotv"); + expect(element.props.canonicalCatalogUrl).toBe("/social/instagram/bravotv/catalog"); + expect(element.props.variantLabel).toBe("Alt 1"); + }); + + it("keeps the Alt 1 suffix when canonicalizing platform and handle params", async () => { + const page = await import("@/app/social/[platform]/[handle]/catalog/alt-1/page"); + + await expect( + page.default({ + params: Promise.resolve({ + platform: "Instagram", + handle: "@BravoTV", + }), + }), + ).rejects.toThrow("NEXT_REDIRECT:/social/instagram/bravotv/catalog/alt-1"); + }); + + it("rejects invalid handles with notFound", async () => { + const page = await import("@/app/social/[platform]/[handle]/catalog/alt-1/page"); + + await expect( + page.default({ + params: Promise.resolve({ + platform: "instagram", + handle: "bad handle", + }), + }), + ).rejects.toThrow("NOT_FOUND"); + }); +}); diff --git a/apps/web/tests/social-account-catalog-recent-runs-route.test.ts b/apps/web/tests/social-account-catalog-recent-runs-route.test.ts new file mode 100644 index 00000000..616671c7 --- /dev/null +++ b/apps/web/tests/social-account-catalog-recent-runs-route.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { requireAdminMock, queryMock, socialProxyErrorResponseMock } = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + queryMock: vi.fn(), + socialProxyErrorResponseMock: vi.fn(), +})); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, +})); + +vi.mock("@/lib/server/postgres", () => ({ + query: queryMock, +})); + +vi.mock("@/lib/server/trr-api/social-admin-proxy", () => ({ + socialProxyErrorResponse: socialProxyErrorResponseMock, +})); + +import { GET } from "@/app/api/admin/trr-api/social/profiles/[platform]/[handle]/catalog/runs/recent/route"; + +describe("social account catalog recent runs route", () => { + beforeEach(() => { + requireAdminMock.mockReset(); + queryMock.mockReset(); + socialProxyErrorResponseMock.mockReset(); + + requireAdminMock.mockResolvedValue({ uid: "admin-1", provider: "firebase" }); + socialProxyErrorResponseMock.mockImplementation((error: unknown) => + Response.json({ error: String(error), code: "BACKEND_UNREACHABLE" }, { status: 502 }), + ); + }); + + it("returns recent catalog runs without loading the profile summary", async () => { + queryMock.mockResolvedValue({ + rows: [ + { + job_id: "job-running", + run_id: "run-running", + status: "running", + created_at: new Date("2026-07-01T14:00:00Z"), + started_at: new Date("2026-07-01T14:01:00Z"), + completed_at: null, + error_message: null, + run_config: { + catalog_action: "backfill", + catalog_action_scope: "full_history", + selected_tasks: ["post_details", "comments", "media"], + attached_followups: { + comments: { + run_id: "comments-run-1", + status: "pending", + state: "pending", + source: "deferred_after_catalog", + }, + }, + }, + }, + ], + }); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/@bravotv/catalog/runs/recent?limit=99"), + { params: Promise.resolve({ platform: "instagram", handle: "@bravotv" }) }, + ); + const body = (await response.json()) as { + handle?: string; + catalog_recent_runs?: Array<{ run_id?: string; selected_tasks?: string[] }>; + }; + + expect(response.status).toBe(200); + expect(body.handle).toBe("bravotv"); + expect(body.catalog_recent_runs).toEqual([ + expect.objectContaining({ + run_id: "run-running", + selected_tasks: ["post_details", "comments", "media"], + }), + ]); + expect(queryMock).toHaveBeenCalledWith(expect.not.stringContaining("/summary"), [ + "shared_account_catalog_backfill", + "instagram", + "bravotv", + expect.arrayContaining(["shared_account_posts", "analytics_refresh"]), + 25, + ]); + }); + + it("mutes pending attached lanes when the parent run is cancelled", async () => { + queryMock.mockResolvedValue({ + rows: [ + { + job_id: null, + run_id: "run-cancelled", + status: "cancelled", + created_at: "2026-06-30T14:00:00.000Z", + started_at: null, + completed_at: "2026-06-30T14:30:00.000Z", + error_message: null, + run_config: { + attached_followups: { + comments: { + status: "pending", + state: "pending", + source: "deferred_after_catalog", + }, + media: { + status: "queued", + state: "pending", + source: "catalog_media_mirror", + enqueued_job_count: 0, + }, + }, + }, + }, + ], + }); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/bravotv/catalog/runs/recent"), + { params: Promise.resolve({ platform: "instagram", handle: "bravotv" }) }, + ); + const body = (await response.json()) as { + catalog_recent_runs?: Array<{ + attached_followups?: { + comments?: { status?: string; state?: string }; + media?: { status?: string; state?: string }; + }; + }>; + }; + const run = body.catalog_recent_runs?.[0]; + + expect(response.status).toBe(200); + expect(run?.attached_followups?.comments).toEqual( + expect.objectContaining({ status: "cancelled", state: "cancelled" }), + ); + expect(run?.attached_followups?.media).toEqual( + expect.objectContaining({ status: "cancelled", state: "cancelled" }), + ); + }); + + it("rejects unsupported profiles before querying runs", async () => { + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/tiktok/bravotv/catalog/runs/recent"), + { params: Promise.resolve({ platform: "tiktok", handle: "bravotv" }) }, + ); + const body = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("unsupported_profile"); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("returns the shared proxy error response when recent runs cannot load", async () => { + const error = new Error("database timed out"); + queryMock.mockRejectedValue(error); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/bravotv/catalog/runs/recent"), + { params: Promise.resolve({ platform: "instagram", handle: "bravotv" }) }, + ); + const body = (await response.json()) as { code?: string; error?: string }; + + expect(response.status).toBe(502); + expect(body.code).toBe("BACKEND_UNREACHABLE"); + expect(body.error).toContain("database timed out"); + expect(socialProxyErrorResponseMock).toHaveBeenCalledWith( + error, + "[api] Failed to load social account catalog recent runs", + ); + }); +}); diff --git a/apps/web/tests/social-account-catalog-run-progress-route.test.ts b/apps/web/tests/social-account-catalog-run-progress-route.test.ts index 017829c6..3ff812fd 100644 --- a/apps/web/tests/social-account-catalog-run-progress-route.test.ts +++ b/apps/web/tests/social-account-catalog-run-progress-route.test.ts @@ -69,7 +69,7 @@ describe("social account catalog run progress proxy route", () => { ); }); - it("uses a shorter timeout and degraded fallback for fast polling", async () => { + it("uses the progress timeout and degraded fallback for fast polling", async () => { fetchSocialBackendJsonMock.mockRejectedValueOnce(new Error("timeout")); const response = await GET( @@ -100,7 +100,7 @@ describe("social account catalog run progress proxy route", () => { "/profiles/instagram/bravotv/catalog/runs/run-fast-1/progress?recent_log_limit=25&fast=1", expect.objectContaining({ retries: 0, - timeoutMs: 12_000, + timeoutMs: 30_000, }), ); expect(socialProxyErrorResponseMock).not.toHaveBeenCalled(); diff --git a/apps/web/tests/social-account-hashtag-timeline.runtime.test.tsx b/apps/web/tests/social-account-hashtag-timeline.runtime.test.tsx index 166b7062..258c23af 100644 --- a/apps/web/tests/social-account-hashtag-timeline.runtime.test.tsx +++ b/apps/web/tests/social-account-hashtag-timeline.runtime.test.tsx @@ -62,6 +62,7 @@ vi.mock("@/lib/admin/show-admin-routes", async () => { }); import SocialAccountProfilePage from "@/components/admin/SocialAccountProfilePage"; +import SocialAccountProfileHashtagTimelineChart from "@/components/admin/SocialAccountProfileHashtagTimelineChart"; const jsonResponse = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { @@ -179,6 +180,38 @@ describe("SocialAccountProfile hashtag timeline", () => { expect(screen.getByText("Unknown Hashtags")).toBeInTheDocument(); }); + it("renders dropped-out hashtags on a separate rail below rank ten", () => { + render( + , + ); + + expect(screen.getByRole("img", { name: "Bump chart showing yearly top ten Instagram hashtags" })).toBeInTheDocument(); + expect(screen.getAllByText(/Dropped out \(11\+\)/)).toHaveLength(2); + }); + it("shows the quiet empty state when the timeline has fewer than two years", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); diff --git a/apps/web/tests/social-account-live-profile-total-route.test.ts b/apps/web/tests/social-account-live-profile-total-route.test.ts new file mode 100644 index 00000000..9920cd15 --- /dev/null +++ b/apps/web/tests/social-account-live-profile-total-route.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +const { requireAdminMock, toVerifiedAdminContextMock, fetchSocialBackendJsonMock, socialProxyErrorResponseMock } = + vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + toVerifiedAdminContextMock: vi.fn(), + fetchSocialBackendJsonMock: vi.fn(), + socialProxyErrorResponseMock: vi.fn(), + })); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, + toVerifiedAdminContext: toVerifiedAdminContextMock, +})); + +vi.mock("@/lib/server/trr-api/social-admin-proxy", () => ({ + fetchSocialBackendJson: fetchSocialBackendJsonMock, + socialProxyErrorResponse: socialProxyErrorResponseMock, +})); + +import { GET } from "@/app/api/admin/trr-api/social/profiles/[platform]/[handle]/live-profile-total/route"; + +describe("social account live profile total route", () => { + beforeEach(() => { + requireAdminMock.mockReset(); + toVerifiedAdminContextMock.mockReset(); + fetchSocialBackendJsonMock.mockReset(); + socialProxyErrorResponseMock.mockReset(); + + requireAdminMock.mockResolvedValue({ uid: `admin-${crypto.randomUUID()}`, provider: "firebase" }); + toVerifiedAdminContextMock.mockReturnValue({ uid: "admin-1" }); + socialProxyErrorResponseMock.mockImplementation((error: unknown) => + NextResponse.json({ error: error instanceof Error ? error.message : "failed" }, { status: 502 }), + ); + }); + + it("returns a fast degraded fallback when the backend total times out without stale cache", async () => { + fetchSocialBackendJsonMock.mockRejectedValueOnce(new Error("TRR-Backend request timed out.")); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/bravotv/live-profile-total"), + { params: Promise.resolve({ platform: "instagram", handle: "bravotv" }) }, + ); + const payload = (await response.json()) as { + status?: string; + degraded?: boolean; + live_total_posts_current?: number | null; + }; + + expect(response.status).toBe(200); + expect(response.headers.get("x-trr-cache")).toBe("fallback"); + expect(response.headers.get("x-trr-cacheable")).toBe("0"); + expect(payload.status).toBe("degraded"); + expect(payload.degraded).toBe(true); + expect(payload.live_total_posts_current).toBeNull(); + expect(socialProxyErrorResponseMock).not.toHaveBeenCalled(); + expect(fetchSocialBackendJsonMock).toHaveBeenCalledWith( + "/profiles/instagram/bravotv/live-profile-total", + expect.objectContaining({ + retries: 0, + timeoutMs: 4_000, + }), + ); + }); +}); diff --git a/apps/web/tests/social-account-profile-backfill-command.test.ts b/apps/web/tests/social-account-profile-backfill-command.test.ts index 415eb67f..ba5f9b73 100644 --- a/apps/web/tests/social-account-profile-backfill-command.test.ts +++ b/apps/web/tests/social-account-profile-backfill-command.test.ts @@ -25,6 +25,7 @@ describe("buildLocalCatalogCommand", () => { expect(defaultLocalCatalogCommandSelectedTasks("instagram", "backfill")).toEqual([ "post_details", "comments", + "media", ]); expect(defaultLocalCatalogCommandSelectedTasks("tiktok", "backfill")).toEqual([ "post_details", diff --git a/apps/web/tests/social-account-profile-catalog-diagnostics.test.ts b/apps/web/tests/social-account-profile-catalog-diagnostics.test.ts new file mode 100644 index 00000000..2f22ebb2 --- /dev/null +++ b/apps/web/tests/social-account-profile-catalog-diagnostics.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { buildCatalogProgressDiagnosticRows } from "@/components/admin/SocialAccountProfilePage"; +import type { SocialAccountCatalogRunProgressSnapshot } from "@/lib/admin/social-account-profile"; + +const baseProgress = ( + overrides: Partial = {}, +): SocialAccountCatalogRunProgressSnapshot => ({ + run_id: "run-diag-1", + run_status: "running", + source_scope: "bravo", + stages: {}, + per_handle: [], + recent_log: [], + ...overrides, +}); + +describe("buildCatalogProgressDiagnosticRows comments-skip + worker reconciliation", () => { + it("surfaces the comments-skip reason, detail, and operator action", () => { + const rows = buildCatalogProgressDiagnosticRows( + baseProgress({ + comments_skip_reason: "posts_auth_blocked", + comments_skip_detail: "Instagram posts auth was blocked before commentable targets were ready.", + comments_operator_action: "Complete manual Instagram auth, then rerun the comments lane.", + }), + ); + + const skipRow = rows.find((row) => row.key === "comments-skip-reason"); + expect(skipRow).toBeDefined(); + expect(skipRow?.label).toBe("Comments Skipped"); + expect(skipRow?.value).toBe("Posts auth blocked"); + expect(skipRow?.detail).toContain("Instagram posts auth was blocked"); + expect(skipRow?.detail).toContain("Operator action: Complete manual Instagram auth"); + }); + + it("falls back to a humanized token for unknown skip reasons and omits the row when absent", () => { + const unknown = buildCatalogProgressDiagnosticRows( + baseProgress({ comments_skip_reason: "some_new_backend_reason" }), + ); + const unknownRow = unknown.find((row) => row.key === "comments-skip-reason"); + expect(unknownRow?.value).toBe("some new backend reason"); + expect(unknownRow?.detail).toBeNull(); + + const none = buildCatalogProgressDiagnosticRows(baseProgress()); + expect(none.some((row) => row.key === "comments-skip-reason")).toBe(false); + }); + + it("shows applied-vs-requested detail workers, the binding cap, and the cap note", () => { + const rows = buildCatalogProgressDiagnosticRows( + baseProgress({ + selected_tasks: ["post_details"], + requested_details_worker_count: 8, + details_refresh_worker_count: 4, + live_apply_binding_cap: 4, + worker_cap_note: "Capped to 4 by the live apply binding cap.", + }), + ); + + const workerRow = rows.find((row) => row.key === "detail-worker-count"); + expect(workerRow).toBeDefined(); + expect(workerRow?.value).toBe("4 applied · 8 requested"); + expect(workerRow?.detail).toContain("Binding cap 4."); + expect(workerRow?.detail).toContain("Capped to 4 by the live apply binding cap."); + }); + + it("keeps the simple worker count when requested equals applied", () => { + const rows = buildCatalogProgressDiagnosticRows( + baseProgress({ + selected_tasks: ["post_details"], + requested_details_worker_count: 4, + details_refresh_worker_count: 4, + }), + ); + + const workerRow = rows.find((row) => row.key === "detail-worker-count"); + expect(workerRow?.value).toBe("4 workers"); + }); +}); diff --git a/apps/web/tests/social-account-profile-completion-summary-route.test.ts b/apps/web/tests/social-account-profile-completion-summary-route.test.ts new file mode 100644 index 00000000..2735ae14 --- /dev/null +++ b/apps/web/tests/social-account-profile-completion-summary-route.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { requireAdminMock, queryMock, socialProxyErrorResponseMock } = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + queryMock: vi.fn(), + socialProxyErrorResponseMock: vi.fn(), +})); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, +})); + +vi.mock("@/lib/server/postgres", () => ({ + query: queryMock, +})); + +vi.mock("@/lib/server/trr-api/social-admin-proxy", () => ({ + socialProxyErrorResponse: socialProxyErrorResponseMock, +})); + +import { GET } from "@/app/api/admin/trr-api/social/profiles/[platform]/[handle]/completion-summary/route"; + +describe("social account profile completion summary route", () => { + beforeEach(() => { + requireAdminMock.mockReset(); + queryMock.mockReset(); + socialProxyErrorResponseMock.mockReset(); + + requireAdminMock.mockResolvedValue({ uid: "admin-1", provider: "firebase" }); + socialProxyErrorResponseMock.mockImplementation((error: unknown) => + Response.json({ error: String(error), code: "BACKEND_UNREACHABLE" }, { status: 502 }), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("defaults to the current year and counts collab comment gaps from health-aware matches", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-29T12:00:00Z")); + + queryMock.mockResolvedValue({ + rows: [ + { + total_posts: "3", + total_reported_comments: "1200", + saved_comments: "780", + missing_comments: "420", + accounted_comments: "1200", + comments_finished: "1", + comments_in_progress: "2", + comments_not_started: "0", + details_finished: "2", + details_not_started: "1", + media_finished: "1", + media_in_progress: "1", + media_not_started: "1", + }, + ], + }); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/bravotv/completion-summary"), + { params: Promise.resolve({ platform: "instagram", handle: "bravotv" }) }, + ); + const body = (await response.json()) as { + year: number; + total_posts: number; + total_reported_comments: number; + saved_comments: number; + missing_comments: number; + lanes: { + comments: { finished: number; in_progress: number; not_started: number }; + }; + }; + + expect(response.status).toBe(200); + expect(body.year).toBe(2026); + expect(body.total_posts).toBe(3); + expect(body.total_reported_comments).toBe(1200); + expect(body.saved_comments).toBe(780); + expect(body.missing_comments).toBe(420); + expect(body.lanes.comments).toEqual({ + finished: 1, + in_progress: 2, + not_started: 0, + }); + + expect(queryMock).toHaveBeenCalledWith(expect.any(String), ["bravotv", 2026]); + const sql = String(queryMock.mock.calls[0]?.[0] ?? ""); + expect(sql).toContain("social.comment_capture_health"); + expect(sql).toContain("cp.owner_username"); + expect(sql).toContain("p.owner_username"); + expect(sql).toContain("p.username"); + expect(sql).toContain("cp.collaborators"); + expect(sql).toContain("p.collaborators"); + expect(sql).toContain("source_account"); + expect(sql).toContain("p.raw_data"); + expect(sql).not.toMatch(/\bp\.comments_count\b/); + }); + + it("falls back to the current year when the year query param is invalid", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-01T12:00:00Z")); + queryMock.mockResolvedValue({ rows: [] }); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/bravotv/completion-summary?year=nope"), + { params: Promise.resolve({ platform: "instagram", handle: "@bravotv" }) }, + ); + const body = (await response.json()) as { year: number; handle: string; total_posts: number }; + + expect(response.status).toBe(200); + expect(body.year).toBe(2026); + expect(body.handle).toBe("bravotv"); + expect(body.total_posts).toBe(0); + expect(queryMock).toHaveBeenCalledWith(expect.any(String), ["bravotv", 2026]); + }); + + it("rejects unsupported platforms before querying completion data", async () => { + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/tiktok/bravotv/completion-summary"), + { params: Promise.resolve({ platform: "tiktok", handle: "bravotv" }) }, + ); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(400); + expect(body.error).toBe("unsupported_profile"); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("returns the shared proxy error response when the completion query fails", async () => { + const error = new Error("column p.comments_count does not exist"); + queryMock.mockRejectedValue(error); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/bravotv/completion-summary?year=2026"), + { params: Promise.resolve({ platform: "instagram", handle: "bravotv" }) }, + ); + const body = (await response.json()) as { error: string; code: string }; + + expect(response.status).toBe(502); + expect(body.code).toBe("BACKEND_UNREACHABLE"); + expect(body.error).toContain("column p.comments_count does not exist"); + expect(socialProxyErrorResponseMock).toHaveBeenCalledWith( + error, + "[api] Failed to load social completion summary", + ); + }); +}); diff --git a/apps/web/tests/social-account-profile-page.hashtag-preview.test.ts b/apps/web/tests/social-account-profile-page.hashtag-preview.test.ts index 85804754..0c321097 100644 --- a/apps/web/tests/social-account-profile-page.hashtag-preview.test.ts +++ b/apps/web/tests/social-account-profile-page.hashtag-preview.test.ts @@ -22,6 +22,7 @@ describe("shouldUseSummaryTopHashtagsPreview", () => { expect( shouldUseSummaryTopHashtagsPreview({ activeTab: "stats", + hashtagAssignmentStatus: "all", hashtagWindow: "all", summaryTopHashtags, hasLoadedExactWindow: false, @@ -33,6 +34,7 @@ describe("shouldUseSummaryTopHashtagsPreview", () => { expect( shouldUseSummaryTopHashtagsPreview({ activeTab: "stats", + hashtagAssignmentStatus: "all", hashtagWindow: "all", summaryTopHashtags, hasLoadedExactWindow: true, @@ -44,6 +46,7 @@ describe("shouldUseSummaryTopHashtagsPreview", () => { expect( shouldUseSummaryTopHashtagsPreview({ activeTab: "hashtags", + hashtagAssignmentStatus: "all", hashtagWindow: "all", summaryTopHashtags, hasLoadedExactWindow: false, @@ -53,10 +56,21 @@ describe("shouldUseSummaryTopHashtagsPreview", () => { expect( shouldUseSummaryTopHashtagsPreview({ activeTab: "stats", + hashtagAssignmentStatus: "all", hashtagWindow: "30d", summaryTopHashtags, hasLoadedExactWindow: false, }), ).toBe(false); + + expect( + shouldUseSummaryTopHashtagsPreview({ + activeTab: "stats", + hashtagAssignmentStatus: "unassigned", + hashtagWindow: "all", + summaryTopHashtags, + hasLoadedExactWindow: false, + }), + ).toBe(false); }); }); diff --git a/apps/web/tests/social-account-profile-page.runtime.test.tsx b/apps/web/tests/social-account-profile-page.runtime.test.tsx index 092a7d2f..0887b711 100644 --- a/apps/web/tests/social-account-profile-page.runtime.test.tsx +++ b/apps/web/tests/social-account-profile-page.runtime.test.tsx @@ -269,7 +269,7 @@ const healthyCookieHealth = (platform: string) => ({ source_kind: "default_file", }); -const INSTAGRAM_BACKFILL_DEFAULT_TASKS = ["post_details", "comments"] as const; +const INSTAGRAM_BACKFILL_DEFAULT_TASKS = ["post_details", "comments", "media"] as const; const TIKTOK_BACKFILL_DEFAULT_TASKS = ["post_details", "comments", "media"] as const; const createStorageMock = (): Storage => { @@ -350,7 +350,7 @@ describe("SocialAccountProfilePage", () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/summary")) { - return jsonResponse(baseSummary); + return jsonResponse({ ...baseSummary, network_name: "Bravo" }); } if (url.includes("/snapshot")) { return jsonResponse({ @@ -411,7 +411,7 @@ describe("SocialAccountProfilePage", () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/summary")) { - return jsonResponse(baseSummary); + return jsonResponse({ ...baseSummary, network_name: "Bravo" }); } if (url.includes("/snapshot")) { return jsonResponse({ @@ -603,7 +603,7 @@ describe("SocialAccountProfilePage", () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/summary")) { - return jsonResponse(baseSummary); + return jsonResponse({ ...baseSummary, network_name: "Bravo" }); } throw new Error(`Unhandled request: ${url}`); }); @@ -886,7 +886,9 @@ describe("SocialAccountProfilePage", () => { render(); - expect(await screen.findByText("Showing cached dashboard data from 2 minutes ago.")).toBeInTheDocument(); + const statusRow = await screen.findByRole("status"); + expect(statusRow).toHaveTextContent("Data status: Cached"); + expect(statusRow).toHaveTextContent("updated 2 minutes ago"); expect(screen.getByText("All-time Saved Comments")).toBeInTheDocument(); }); @@ -915,9 +917,9 @@ describe("SocialAccountProfilePage", () => { render(); - expect( - await screen.findByText("Backend dashboard refresh failed. Showing the last successful profile data."), - ).toBeInTheDocument(); + const statusRow = await screen.findByRole("status"); + expect(statusRow).toHaveTextContent("Data status: Degraded"); + expect(statusRow).toHaveTextContent("showing the last successful profile data"); expect(screen.getByText("All-time Saved Comments")).toBeInTheDocument(); expect(screen.queryByText("Failed to load social account profile summary")).not.toBeInTheDocument(); }); @@ -1586,6 +1588,40 @@ describe("SocialAccountProfilePage", () => { }); }); + it("falls back to a supported tab when the account switcher target cannot render the current tab", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ ...baseSummary, network_name: "Bravo" }); + } + if (url.includes("/social/shared/sources")) { + return jsonResponse({ + sources: [ + { + platform: "twitter", + account_handle: "bravodailydish", + display_name: "Bravo Daily Dish", + network_name: "Bravo", + metadata: { profile_kind: "show_official" }, + }, + ], + }); + } + if (url.includes("/socialblade")) { + return jsonResponse(baseSocialBladeResponse); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: /Instagram/i })); + + const target = await screen.findByRole("link", { name: /Bravo Daily Dish/i }); + expect(target).toHaveAttribute("href", "/social/twitter/bravodailydish"); + expect(target).not.toHaveAttribute("href", "/social/twitter/bravodailydish/socialblade"); + }); + it("shows the Comments tab only for platforms with saved discussion support", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); @@ -1639,6 +1675,10 @@ describe("SocialAccountProfilePage", () => { mode: "profile", source_scope: "network", refresh_policy: "stale_or_missing", + max_comments_per_post: 0, + comments_load_strategy: "public_relay", + date_start: null, + date_end: null, }), ); return jsonResponse({ run_id: "comments-run-12345678", status: "queued" }); @@ -1716,6 +1756,7 @@ describe("SocialAccountProfilePage", () => { }); it("queues an incomplete fill comments scrape from the comments tab", async () => { + const scrapeCalls: Array> = []; mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes("/summary")) { @@ -1729,30 +1770,67 @@ describe("SocialAccountProfilePage", () => { } if (url.includes("/comments/scrape")) { expect(init?.method).toBe("POST"); - expect(init?.body).toBe( - JSON.stringify({ - mode: "profile", - source_scope: "network", - refresh_policy: "stale_or_missing", - target_filter: "incomplete", - }), - ); + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}; + scrapeCalls.push(body as Record); return jsonResponse({ - run_id: "incfill1-12345678", status: "queued", - auth_repair_attempted: true, - auth_repair_status: "succeeded", - comments_auth_probe: { status: "valid", shortcode: "SHORT1" }, }); } - if (url.includes("/comments/runs/incfill1-12345678/progress")) { + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Incomplete Fill" })); + + await waitFor(() => { + expect(scrapeCalls).toHaveLength(1); + }); + expect(scrapeCalls[0]).toMatchObject({ + mode: "profile", + source_scope: "network", + refresh_policy: "stale_or_missing", + target_filter: "incomplete", + max_comments_per_post: 0, + comments_load_strategy: "public_relay", + date_start: null, + date_end: null, + }); + expect(scrapeCalls[0]).not.toHaveProperty("dry_run"); + }); + + it("previews incomplete fill with public relay without enqueueing a run", async () => { + const scrapeCalls: Array<{ url: string; body: Record }> = []; + setWindowLocation( + "https://admin.therealityreport.com/social/instagram/bravotv/comments?date_start=2026-01-01&date_end=2027-01-01", + ); + + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse(baseSummary); + } + if (url.includes("/posts?page=1&page_size=25&comments_only=true")) { return jsonResponse({ - run_id: "incfill1-12345678", - platform: "instagram", - account_handle: "bravotv", - run_status: "completed", - job_status: "completed", - target_filter: "incomplete", + items: [], + pagination: { page: 1, page_size: 25, total: 0, total_pages: 1 }, + }); + } + if (url.includes("/comments/audit-cursor-retries")) { + return jsonResponse({ ok: true, selected_target_source_ids_count: 0, rows: [] }); + } + if (url.includes("/comments/scrape")) { + const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}; + scrapeCalls.push({ url, body: body as Record }); + return jsonResponse({ + dry_run: true, + target_source_ids_count: 12, + comments_shard_count: 2, + comments_load_strategy: "public_relay", + comments_session_scope: "public_relay", + date_start: "2026-01-01T00:00:00+00:00", + date_end: "2027-01-01T00:00:00+00:00", + sample_target_source_ids: ["DTgXh94kXyo", "DYiDH6pN-1Z"], }); } throw new Error(`Unhandled request: ${url}`); @@ -1760,9 +1838,32 @@ describe("SocialAccountProfilePage", () => { render(); - fireEvent.click(await screen.findByRole("button", { name: "Incomplete Fill" })); + fireEvent.click(await screen.findByRole("button", { name: "Preview Incomplete Fill" })); - expect(await screen.findByText("Manual Instagram Auth validated. Incomplete Fill queued. Run incfill1.")).toBeInTheDocument(); + await waitFor(() => { + expect(scrapeCalls).toHaveLength(1); + }); + expect(scrapeCalls[0].body).toMatchObject({ + mode: "profile", + source_scope: "network", + refresh_policy: "stale_or_missing", + target_filter: "incomplete", + max_comments_per_post: 0, + comments_load_strategy: "public_relay", + date_start: "2026-01-01", + date_end: "2027-01-01", + dry_run: true, + }); + expect( + await screen.findByText( + /Incomplete Fill preview: 12 targets \/ 2 shards \/ public relay .* DTgXh94kXyo, DYiDH6pN-1Z/i, + ), + ).toBeInTheDocument(); + expect(screen.getByText("Incomplete Fill Preview")).toBeInTheDocument(); + expect(screen.getByText("Dry-run only. No run was queued and no workers were dispatched.")).toBeInTheDocument(); + expect(screen.getByText(/Public relay does not require auth cookies/i)).toBeInTheDocument(); + expect(scrapeCalls[0].url).not.toContain("dry_run=true"); + expect(screen.queryByText(/Run incfill/i)).not.toBeInTheDocument(); }); it("keeps incomplete fill clickable when no incomplete comment targets are known", async () => { @@ -2545,7 +2646,7 @@ describe("SocialAccountProfilePage", () => { render(); expect( - await screen.findByText("Stopped by operator. 7 targets remain; run Sync Comments to resume."), + await screen.findByText("Stopped by operator. 7 targets remain; use Incomplete Fill to continue only unfinished posts."), ).toBeInTheDocument(); }); @@ -2595,7 +2696,7 @@ describe("SocialAccountProfilePage", () => { const { unmount } = render(); expect( - await screen.findByText("Stopped by operator. 7 targets remain; run Sync Comments to resume."), + await screen.findByText("Stopped by operator. 7 targets remain; use Incomplete Fill to continue only unfinished posts."), ).toBeInTheDocument(); await waitFor(() => { expect(summaryRequests).toBeGreaterThanOrEqual(2); @@ -2908,6 +3009,70 @@ describe("SocialAccountProfilePage", () => { expect(within(dialog).getByText("14 Instagram rows")).toBeInTheDocument(); }); + it("opens a comments modal once when a post link is double-clicked", async () => { + let commentFetches = 0; + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/snapshot")) { + return profileSnapshotResponse(baseSummary); + } + if (url.includes("/summary")) { + return jsonResponse(baseSummary); + } + if (url.includes("/posts?page=1&page_size=25&comments_only=true")) { + return jsonResponse({ + items: [ + { + id: "post-1", + source_id: "DVfQnTcjsCA", + platform: "instagram", + account_handle: "bravotv", + content: "Double click caption.", + url: "https://www.instagram.com/p/DVfQnTcjsCA/", + posted_at: "2026-04-09T14:30:00.000Z", + saved_comments: 1, + metrics: { likes: 10, comments_count: 1 }, + }, + ], + pagination: { page: 1, page_size: 25, total: 1, total_pages: 1 }, + }); + } + if (url.includes("/comments?post_source_id=DVfQnTcjsCA&page=1&page_size=25")) { + commentFetches += 1; + return jsonResponse({ + items: [ + { + id: "comment-1", + comment_id: "178", + post_id: "post-1", + post_source_id: "DVfQnTcjsCA", + username: "andycohen", + text: "Loaded once", + likes: 1, + is_reply: false, + created_at: "2026-04-09T15:00:00.000Z", + parent_comment_id: null, + }, + ], + pagination: { page: 1, page_size: 25, total: 1, total_pages: 1 }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + const postLink = await screen.findByRole("link", { name: "DVfQnTcjsCA" }); + fireEvent.click(postLink); + fireEvent.click(postLink); + + const dialog = await screen.findByRole("dialog", { name: "Comments for DVfQnTcjsCA" }); + expect(within(dialog).getByText("Loaded once")).toBeInTheDocument(); + await waitFor(() => { + expect(commentFetches).toBe(1); + }); + }); + it("sorts comments table columns through the posts query", async () => { const postRequestUrls: string[] = []; @@ -3050,6 +3215,71 @@ describe("SocialAccountProfilePage", () => { expect(within(dialog).getByText("1,551 Instagram rows")).toBeInTheDocument(); }); + it("keeps the post comments modal closed after a posts refresh", async () => { + setWindowLocation("https://admin.therealityreport.com/social/instagram/bravotv/comments?post=DVfQnTcjsCA"); + const postsRequests: string[] = []; + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse(baseSummary); + } + if (url.includes("/posts?page=1&page_size=25&comments_only=true")) { + postsRequests.push(url); + return jsonResponse({ + items: [ + { + id: "post-1", + source_id: "DVfQnTcjsCA", + platform: "instagram", + account_handle: "bravotv", + content: "URL-selected caption.", + url: "https://www.instagram.com/p/DVfQnTcjsCA/", + posted_at: "2026-04-09T14:30:00.000Z", + saved_comments: 2, + metrics: { likes: 842, comments_count: 2 }, + }, + ], + pagination: { page: 1, page_size: 25, total: 1, total_pages: 1 }, + }); + } + if (url.includes("/comments?post_source_id=DVfQnTcjsCA&page=1&page_size=25")) { + return jsonResponse({ + items: [ + { + id: "comment-1", + comment_id: "178", + post_id: "post-1", + post_source_id: "DVfQnTcjsCA", + username: "andycohen", + text: "Direct URL comment", + likes: 22, + is_reply: false, + created_at: "2026-04-09T15:00:00.000Z", + parent_comment_id: null, + }, + ], + pagination: { page: 1, page_size: 25, total: 1, total_pages: 1 }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + const dialog = await screen.findByRole("dialog", { name: "Comments for DVfQnTcjsCA" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(screen.queryByRole("dialog", { name: "Comments for DVfQnTcjsCA" })).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Sort by Likes descending" })); + await waitFor(() => { + expect(postsRequests.some((url) => url.includes("sort_by=likes"))).toBe(true); + }); + expect(screen.queryByRole("dialog", { name: "Comments for DVfQnTcjsCA" })).not.toBeInTheDocument(); + }); + it("filters commentable posts to incomplete comment totals through the posts query", async () => { const postRequestUrls: string[] = []; @@ -3552,6 +3782,48 @@ describe("SocialAccountProfilePage", () => { }); }); + it("renders SocialBlade chart and table rows chronologically when the response order is reversed", async () => { + mocks.fetchAdminWithAuth.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/summary")) { + return Promise.resolve(jsonResponse(baseSummary)); + } + if (url.includes("/socialblade")) { + return Promise.resolve( + jsonResponse({ + ...baseSocialBladeResponse, + daily_channel_metrics_60day: { + ...baseSocialBladeResponse.daily_channel_metrics_60day, + data: [ + { Date: "2026-04-08", "Followers Total": "1000" }, + { Date: "2026-04-07", "Followers Total": "990" }, + ], + }, + daily_total_followers_chart: { + ...baseSocialBladeResponse.daily_total_followers_chart, + data: [ + { date: "2026-04-08", followers: 1000 }, + { date: "2026-04-07", followers: 990 }, + ], + }, + }), + ); + } + throw new Error(`Unhandled request: ${url} (${init?.method ?? "GET"})`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Followers Growth")).toBeInTheDocument(); + }); + + expect(screen.getByText("+10")).toBeInTheDocument(); + const rows = Array.from(document.querySelectorAll("tbody tr")); + expect(rows[0]?.textContent).toContain("2026-04-07"); + expect(rows[1]?.textContent).toContain("2026-04-08"); + }); + it("preserves the active source scope across SocialBlade refresh and following pagination", async () => { const refreshBodies: unknown[] = []; const relationshipUrls: string[] = []; @@ -3631,6 +3903,51 @@ describe("SocialAccountProfilePage", () => { ); }); + it("does not offer another following page when fallback pagination has no continuation signal", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse(baseSummary); + } + if (url.includes("/relationships")) { + const searchParams = new URL(url, "http://localhost").searchParams; + return jsonResponse({ + owner: { username: "bravotv" }, + relationship_type: "following", + items: Array.from({ length: 100 }, (_, index) => ({ + id: `relationship-${index + 1}`, + relationship_type: "following", + user: { + username: `fallback_${index + 1}`, + full_name: `Fallback ${index + 1}`, + }, + })), + pagination: { + page: Number(searchParams.get("page") ?? "1"), + page_size: Number(searchParams.get("page_size") ?? "100"), + }, + }); + } + if (url.includes("/socialblade")) { + return jsonResponse(baseSocialBladeResponse); + } + throw new Error(`Unhandled request: ${url} (${init?.method ?? "GET"})`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Followers Growth")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Open following list" })); + + await waitFor(() => { + expect(screen.getByText("@fallback_1")).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "Load next following page" })).not.toBeInTheDocument(); + }); + it("surfaces SocialBlade refresh errors on the account tab", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); @@ -3727,7 +4044,9 @@ describe("SocialAccountProfilePage", () => { render(); await waitFor(() => { - expect(screen.getByText("Summary read timed out before completion. Retry in a moment.")).toBeInTheDocument(); + expect( + screen.getByText("Account summary temporarily unavailable. Saved profile totals may be stale until the summary reloads."), + ).toBeInTheDocument(); }); expect(liveSummaryCalls).toBe(0); @@ -3758,7 +4077,9 @@ describe("SocialAccountProfilePage", () => { render(); await waitFor(() => { - expect(screen.getByText("Summary read timed out before completion. Retry in a moment.")).toBeInTheDocument(); + expect( + screen.getByText("Account summary temporarily unavailable. Saved profile totals may be stale until the summary reloads."), + ).toBeInTheDocument(); }); expect(liveSummaryCalls).toBe(1); @@ -3798,7 +4119,9 @@ describe("SocialAccountProfilePage", () => { render(); await waitFor(() => { - expect(screen.getByText("Summary read timed out before completion. Retry in a moment.")).toBeInTheDocument(); + expect( + screen.getByText("Account summary temporarily unavailable. Saved profile totals may be stale until the summary reloads."), + ).toBeInTheDocument(); }); expect( screen.queryByText("Could not reach TRR-Backend. Confirm TRR-Backend is running and TRR_API_URL is correct."), @@ -3905,7 +4228,6 @@ describe("SocialAccountProfilePage", () => { expect(screen.getAllByText("Loading…").length).toBeGreaterThan(0); }); - expect(screen.getByText("Loading account summary…")).toBeInTheDocument(); expect(screen.queryByText("0 / 0")).not.toBeInTheDocument(); expect(screen.queryByText("Never")).not.toBeInTheDocument(); @@ -4042,7 +4364,8 @@ describe("SocialAccountProfilePage", () => { expect(checkboxes).toHaveLength(4); expect(checkboxes[0]).toBeChecked(); expect(checkboxes[1]).toBeChecked(); - expect(checkboxes[2]).not.toBeChecked(); + // Media is now checked by default (all three lanes run by default). + expect(checkboxes[2]).toBeChecked(); fireEvent.click(screen.getByRole("button", { name: "Start Backfill" })); @@ -4137,7 +4460,110 @@ describe("SocialAccountProfilePage", () => { }); }); - it("replaces accepted Instagram launch copy with active progress and keeps launch buttons locked", async () => { + it("requires explicit Live APPLY before a 2025 Instagram backfill is finalized", async () => { + const runId = "11111111-1111-4111-8111-111111111111"; + const requiredConfirmation = `APPLY INSTAGRAM 2025 BACKFILL ${runId}`; + const backfillRequests: Array> = []; + + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse(baseSummary); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + if (url.includes("/snapshot")) { + return jsonResponse({ + summary: baseSummary, + catalog_run_progress: null, + generated_at: "2026-05-03T15:31:47.000Z", + }); + } + if (url.includes(`/catalog/runs/${runId}/progress`)) { + return jsonResponse({ + run_id: runId, + run_status: "queued", + launch_state: "pending_apply_confirmation", + source_scope: "network", + selected_tasks: [...INSTAGRAM_BACKFILL_DEFAULT_TASKS], + effective_selected_tasks: [...INSTAGRAM_BACKFILL_DEFAULT_TASKS], + stages: {}, + per_handle: [], + recent_log: [], + summary: { total_jobs: 0, completed_jobs: 0, failed_jobs: 0, active_jobs: 0 }, + }); + } + if (url.includes("/catalog/backfill")) { + expect(init?.method).toBe("POST"); + const body = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + backfillRequests.push(body); + if (!body.apply_run_id) { + expect(body).toMatchObject({ + source_scope: "network", + backfill_scope: "bounded_window", + date_start: "2025-01-01T00:00:00.000Z", + date_end: "2025-12-31T23:59:59.999Z", + selected_tasks: [...INSTAGRAM_BACKFILL_DEFAULT_TASKS], + }); + return jsonResponse({ + run_id: runId, + status: "queued", + catalog_run_id: runId, + launch_state: "pending_apply_confirmation", + launch_task_resolution_pending: true, + requires_apply_confirmation: true, + apply_required: true, + apply_run_id: runId, + required_confirmation: requiredConfirmation, + }); + } + expect(body).toMatchObject({ + apply_run_id: runId, + operator_confirmation: requiredConfirmation, + }); + return jsonResponse({ + run_id: runId, + status: "queued", + catalog_run_id: runId, + launch_state: "finalizing", + launch_task_resolution_pending: true, + requires_apply_confirmation: false, + apply_required: false, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Backfill Posts" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Backfill Posts" })); + expect(await screen.findByText("Choose what this backfill should run")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Start month (MM-YYYY)"), { target: { value: "2025-01" } }); + fireEvent.change(screen.getByLabelText("End month (MM-YYYY)"), { target: { value: "2025-12" } }); + fireEvent.click(screen.getByRole("button", { name: "Start Backfill" })); + + expect(await screen.findByText("Live APPLY Required")).toBeInTheDocument(); + expect(screen.getByText(requiredConfirmation)).toBeInTheDocument(); + expect(backfillRequests).toHaveLength(1); + expect(backfillRequests[0].apply_run_id).toBeUndefined(); + + fireEvent.click(screen.getByRole("button", { name: "Confirm Live APPLY" })); + + await waitFor(() => { + expect(backfillRequests).toHaveLength(2); + }); + expect(backfillRequests[1]).toMatchObject({ + apply_run_id: runId, + operator_confirmation: requiredConfirmation, + }); + }); + + it("replaces accepted Instagram launch copy with active progress and keeps launch buttons locked", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes("/summary")) { @@ -4276,21 +4702,18 @@ describe("SocialAccountProfilePage", () => { }); }); - it("auto-refreshes unhealthy TikTok cookies before starting backfill", async () => { - const cookieHealthResponses = [ - { - platform: "tiktok", - required: true, - healthy: false, - reason: "expired", - refresh_supported: true, - refresh_available: true, - refresh_action: "cookie_refresh", - refresh_label: "Refresh Cookies", - source_kind: "default_file", - }, - healthyCookieHealth("tiktok"), - ]; + it("starts TikTok backfill through Modal even when local cookies are unhealthy", async () => { + const unhealthyCookieHealth = { + platform: "tiktok", + required: true, + healthy: false, + reason: "expired", + refresh_supported: true, + refresh_available: true, + refresh_action: "cookie_refresh", + refresh_label: "Refresh Cookies", + source_kind: "default_file", + }; mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -4303,16 +4726,10 @@ describe("SocialAccountProfilePage", () => { }); } if (url.includes("/cookies/health")) { - return jsonResponse(cookieHealthResponses.shift() ?? healthyCookieHealth("tiktok")); + return jsonResponse(unhealthyCookieHealth); } if (url.includes("/cookies/refresh")) { - expect(init?.method).toBe("POST"); - return jsonResponse({ - success: true, - healthy: true, - reason: null, - refresh_action: "cookie_refresh", - }); + throw new Error("TikTok backfill should not refresh local cookies before Modal launch"); } if (url.includes("/catalog/backfill")) { expect(init?.method).toBe("POST"); @@ -4441,9 +4858,8 @@ describe("SocialAccountProfilePage", () => { expect(checkboxes).toHaveLength(4); expect(checkboxes[0]).toBeChecked(); expect(checkboxes[1]).toBeChecked(); - expect(checkboxes[2]).not.toBeChecked(); - - fireEvent.click(checkboxes[2]); + // Media is now checked by default (all three lanes run by default), so no click needed. + expect(checkboxes[2]).toBeChecked(); const selectedSummary = Array.from(document.querySelectorAll("p")) .map((element) => element.textContent?.replace(/\s+/g, " ").trim() ?? "") @@ -4615,11 +5031,12 @@ describe("SocialAccountProfilePage", () => { render(); - const recentRunsHeading = await screen.findByText("Recent Catalog Runs"); - const recentRunsSection = recentRunsHeading.closest("section"); - expect(recentRunsSection).not.toBeNull(); - expect(within(recentRunsSection as HTMLElement).getByText("Deferred")).toBeInTheDocument(); - expect(within(recentRunsSection as HTMLElement).getByText("Waiting for target readiness")).toBeInTheDocument(); + const progressHeading = await screen.findByText("Worker and Lane Details"); + const progressSection = progressHeading.closest("section"); + expect(progressSection).not.toBeNull(); + const progress = within(progressSection as HTMLElement); + expect(progress.getByText("Deferred")).toBeInTheDocument(); + expect(progress.getByText("Waiting for target readiness")).toBeInTheDocument(); }); it("uses attached follow-up state for the recent backfill aggregate label", async () => { @@ -4665,6 +5082,194 @@ describe("SocialAccountProfilePage", () => { expect(recentRuns.queryByText("Catalog Completed")).not.toBeInTheDocument(); }); + it("dedupes catalog recent runs and keeps the displayed active run out of history", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + catalog_recent_runs: [ + { + job_id: "active-job-a", + run_id: "active-run-4324", + status: "running", + created_at: "2026-05-03T17:05:53Z", + selected_tasks: ["post_details", "comments", "media"], + effective_selected_tasks: ["post_details", "comments", "media"], + }, + { + job_id: "active-job-b", + run_id: "active-run-4324", + status: "running", + created_at: "2026-05-03T17:05:54Z", + selected_tasks: ["post_details", "comments", "media"], + effective_selected_tasks: ["post_details", "comments", "media"], + }, + { + job_id: "old-job-a", + run_id: "old-run-1234", + status: "cancelled", + created_at: "2026-05-02T17:05:53Z", + }, + ], + }); + } + if (url.includes("/catalog/runs/active-run-4324/progress")) { + return jsonResponse({ + run_id: "active-run-4324", + run_status: "running", + selected_tasks: ["post_details", "comments", "media"], + effective_selected_tasks: ["post_details", "comments", "media"], + post_progress: { + completed_posts: 74, + total_posts: 17574, + }, + summary: { + total_jobs: 2, + completed_jobs: 0, + failed_jobs: 0, + active_jobs: 2, + items_found_total: 74, + }, + stages: {}, + per_handle: [], + recent_log: [], + alerts: [], + }); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + if (url.includes("/catalog/posts")) { + return jsonResponse({ + items: [], + pagination: { page: 1, page_size: 25, total: 0, total_pages: 1 }, + }); + } + if (url.includes("/catalog/review-queue")) { + return jsonResponse({ items: [] }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + expect(await screen.findByText("Live Backfill Status")).toBeInTheDocument(); + const recentRunsHeading = await screen.findByText("Recent Catalog Runs"); + const recentRunsSection = recentRunsHeading.closest("section"); + expect(recentRunsSection).not.toBeNull(); + const recentRuns = within(recentRunsSection as HTMLElement); + expect(recentRuns.queryByText("Run active-r")).not.toBeInTheDocument(); + expect(recentRuns.getByText("Run old-run-")).toBeInTheDocument(); + }); + + it("does not mark cancelled historical catalog runs as still running because a follow-up lane is pending", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + catalog_recent_runs: [ + { + job_id: "cancelled-job-a", + run_id: "cancelled-run-1", + status: "cancelled", + created_at: "2026-05-02T17:05:53Z", + completed_at: "2026-05-02T17:15:53Z", + selected_tasks: ["post_details", "comments", "media"], + effective_selected_tasks: ["post_details", "comments", "media"], + comments_run_id: "comments-pending-1", + attached_followups: { + comments: { + run_id: "comments-pending-1", + state: "pending", + status: "pending", + source: "deferred_after_catalog", + }, + media: { + attachment_id: "media-pending-1", + state: "pending", + status: "queued", + source: "catalog_media_mirror", + enqueued_job_ids: [], + enqueued_job_count: 0, + }, + }, + }, + ], + }); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + const recentRunsHeading = await screen.findByText("Recent Catalog Runs"); + const recentRunsSection = recentRunsHeading.closest("section"); + expect(recentRunsSection).not.toBeNull(); + const recentRuns = within(recentRunsSection as HTMLElement); + expect(recentRuns.getByText("Run Cancelled")).toBeInTheDocument(); + expect(recentRuns.queryByText("Lanes Still Running")).not.toBeInTheDocument(); + expect(recentRuns.getAllByText("Cancelled").length).toBeGreaterThanOrEqual(2); + }); + + it("does not mark failed historical catalog runs as still running because a follow-up lane is pending", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + catalog_recent_runs: [ + { + job_id: "failed-job-a", + run_id: "failed-run-1", + status: "failed", + created_at: "2026-05-02T17:05:53Z", + completed_at: "2026-05-02T17:15:53Z", + selected_tasks: ["post_details", "comments", "media"], + effective_selected_tasks: ["post_details", "comments", "media"], + comments_run_id: "comments-pending-failed-1", + attached_followups: { + comments: { + run_id: "comments-pending-failed-1", + state: "pending", + status: "pending", + source: "deferred_after_catalog", + }, + media: { + attachment_id: "media-pending-failed-1", + state: "pending", + status: "queued", + source: "catalog_media_mirror", + enqueued_job_ids: [], + enqueued_job_count: 0, + }, + }, + }, + ], + }); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + const recentRunsHeading = await screen.findByText("Recent Catalog Runs"); + const recentRunsSection = recentRunsHeading.closest("section"); + expect(recentRunsSection).not.toBeNull(); + const recentRuns = within(recentRunsSection as HTMLElement); + expect(recentRuns.getByText("Run Failed")).toBeInTheDocument(); + expect(recentRuns.queryByText("Lanes Still Running")).not.toBeInTheDocument(); + expect(recentRuns.queryByText("Pending")).not.toBeInTheDocument(); + expect(recentRuns.getAllByText("Not Started").length).toBeGreaterThanOrEqual(1); + }); + it("does not render stale active launch or follow-up states on terminal catalog runs", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); @@ -4868,6 +5473,251 @@ describe("SocialAccountProfilePage", () => { }); }); + it("shows a compact current-year completion split for bounded backfills", async () => { + const expectedYear = new Date().getUTCFullYear(); + + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + catalog_recent_runs: [], + }); + } + if (url.includes("/snapshot")) { + return jsonResponse({ + summary: { + ...baseSummary, + catalog_recent_runs: [], + }, + }); + } + if (url.includes(`/completion-summary?year=${expectedYear}`)) { + return jsonResponse({ + year: expectedYear, + total_posts: 437, + total_reported_comments: 1820, + saved_comments: 1764, + missing_comments: 56, + accounted_comments: 1820, + lanes: { + comments: { finished: 320, in_progress: 18, not_started: 99 }, + details: { finished: 401, in_progress: 11, not_started: 25 }, + media: { finished: 288, in_progress: 44, not_started: 105 }, + }, + }); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + const completionHeading = await screen.findByText(`${expectedYear} Completion`); + const completionCard = completionHeading.closest("div.rounded-2xl.border.border-zinc-200.bg-white"); + expect(completionCard).not.toBeNull(); + const completion = within(completionCard as HTMLElement); + expect(completion.getByText("437 posts")).toBeInTheDocument(); + expect(completion.getByText("1,820 reported comments · 1,764 saved · 56 missing")).toBeInTheDocument(); + expect(completion.getByText("Details")).toBeInTheDocument(); + expect(completion.getByText("Comments")).toBeInTheDocument(); + expect(completion.getByText("Media")).toBeInTheDocument(); + expect(completion.getByText("401 finished")).toBeInTheDocument(); + expect(completion.getByText("320 finished")).toBeInTheDocument(); + expect(completion.getByText("288 finished")).toBeInTheDocument(); + expect( + mocks.fetchAdminWithAuth.mock.calls.some(([input]) => + String(input).includes(`/completion-summary?year=${expectedYear}`), + ), + ).toBe(true); + expect( + mocks.fetchAdminWithAuth.mock.calls.some(([input]) => + String(input).includes("/completion-summary?year=2025"), + ), + ).toBe(false); + }); + + it("shows live catalog-to-comments streaming metadata in the Instagram completion card", async () => { + const expectedYear = new Date().getUTCFullYear(); + const progressPayload = { + run_id: "catalog-stream-run-1", + run_status: "running", + source_scope: "network", + selected_tasks: ["post_details", "comments"], + effective_selected_tasks: ["post_details", "comments"], + comments_run_id: "comments-stream-run-1", + comments_streaming: { + enabled: true, + state: "appending", + source: "catalog_batch_persist", + comments_run_id: "comments-stream-run-1", + targets_seen: 139, + targets_enqueued: 137, + targets_skipped_duplicate: 2, + append_failures: 0, + worker_count: 8, + last_batch_source_ids_count: 3, + last_enqueue_lag_ms: 275, + max_enqueue_lag_ms: 400, + average_enqueue_lag_ms: 337.5, + enqueue_attempt_count: 2, + next_action: { + code: "workers_processing_streamed_posts", + label: "Workers processing streamed posts", + detail: + "Saved catalog posts have been queued to public-first comments workers. Keep the catalog run open while new batches arrive.", + }, + history: [ + { + recorded_at: "2026-07-01T12:00:00Z", + state: "appending", + enqueue_targets_seen: 3, + enqueue_targets_enqueued: 2, + last_enqueue_lag_ms: 275, + }, + ], + }, + stages: {}, + per_handle: [], + recent_log: [], + alerts: [], + summary: { + total_jobs: 1, + completed_jobs: 0, + failed_jobs: 0, + active_jobs: 1, + }, + }; + + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + catalog_recent_runs: [ + { + run_id: "catalog-stream-run-1", + status: "running", + created_at: "2026-06-12T12:00:00Z", + selected_tasks: ["post_details", "comments"], + effective_selected_tasks: ["post_details", "comments"], + comments_run_id: "comments-stream-run-1", + }, + ], + comments_coverage: { + ...baseSummary.comments_coverage, + active_run_id: "comments-stream-run-1", + }, + }); + } + if (url.includes("/snapshot")) { + return jsonResponse({ + summary: { + ...baseSummary, + catalog_recent_runs: [ + { + run_id: "catalog-stream-run-1", + status: "running", + created_at: "2026-06-12T12:00:00Z", + selected_tasks: ["post_details", "comments"], + effective_selected_tasks: ["post_details", "comments"], + comments_run_id: "comments-stream-run-1", + }, + ], + comments_coverage: { + ...baseSummary.comments_coverage, + active_run_id: "comments-stream-run-1", + }, + }, + catalog_run_progress: progressPayload, + }); + } + if (url.includes("/catalog/runs/catalog-stream-run-1/progress")) { + return jsonResponse(progressPayload); + } + if (url.includes("/comments/runs/comments-stream-run-1/progress")) { + return jsonResponse({ + run_id: "comments-stream-run-1", + platform: "instagram", + account_handle: "bravotv", + run_status: "running", + summary: { + comments_processed_total: 320, + comments_inserted_total: 42, + }, + post_progress: { + completed_posts: 27, + total_posts: 137, + }, + worker_counters: { + total: 8, + running: 8, + queued: 0, + completed: 0, + failed: 0, + }, + target_progress_rows: [ + { + source_id: "DVstream1", + shortcode: "DVstream1", + status: "running", + current_phase: "comments_fetch", + latest_reason: "public recovery pending", + reported_comment_count: 12, + saved_comment_count: 4, + missing_comment_gap: 8, + }, + ], + }); + } + if (url.includes(`/completion-summary?year=${expectedYear}`)) { + return jsonResponse({ + year: expectedYear, + total_posts: 437, + total_reported_comments: 1820, + saved_comments: 1764, + missing_comments: 56, + accounted_comments: 1820, + lanes: { + comments: { finished: 320, in_progress: 18, not_started: 99 }, + details: { finished: 401, in_progress: 11, not_started: 25 }, + media: { finished: 288, in_progress: 44, not_started: 105 }, + }, + }); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + const banner = await screen.findByTestId("instagram-comments-streaming-banner"); + const streaming = within(banner); + expect(streaming.getByText("Comments Are Streaming From Saved Catalog Posts")).toBeInTheDocument(); + expect(streaming.getByText("Appending")).toBeInTheDocument(); + expect(streaming.getByText("run comments")).toBeInTheDocument(); + expect(streaming.getByText("137 posts enqueued")).toBeInTheDocument(); + expect(streaming.getByText("139 posts seen")).toBeInTheDocument(); + expect(streaming.getByText("2 skipped as duplicates")).toBeInTheDocument(); + expect(streaming.getByText("8 comment workers")).toBeInTheDocument(); + expect(streaming.getByText("3 posts in latest batch")).toBeInTheDocument(); + expect(streaming.getByText("275 ms enqueue lag")).toBeInTheDocument(); + expect(streaming.getByText("400 ms max lag")).toBeInTheDocument(); + expect(streaming.getByText("337.5 ms average lag")).toBeInTheDocument(); + expect(streaming.getByText("2 enqueue attempts")).toBeInTheDocument(); + expect(streaming.getByText("Next: Workers processing streamed posts")).toBeInTheDocument(); + expect(streaming.getByText(/Saved catalog posts have been queued to public-first comments workers/)).toBeInTheDocument(); + expect(streaming.getByText("Recent Streaming Activity")).toBeInTheDocument(); + expect(streaming.getByText(/Appending · 3 seen · 2 enqueued · 275 ms/)).toBeInTheDocument(); + expect(await screen.findByText("Posts waiting in the streaming comments lane")).toBeInTheDocument(); + expect(screen.getByText("DVstream1")).toBeInTheDocument(); + expect(screen.getByText("Public-first")).toBeInTheDocument(); + }); + it("uses resolved attached follow-up status over stale stage graph lanes", async () => { const attachedFollowups = { comments: { @@ -4979,6 +5829,7 @@ describe("SocialAccountProfilePage", () => { expect(progress.getByText("Cancelled")).toBeInTheDocument(); expect(progress.getByText("New")).toBeInTheDocument(); expect(progress.getByText("Run comments")).toBeInTheDocument(); + expect(progress.getByText("Lanes Pending")).toBeInTheDocument(); }); expect(progress.queryByText("Queued")).not.toBeInTheDocument(); @@ -5139,28 +5990,116 @@ describe("SocialAccountProfilePage", () => { render(); - const rowTitle = await screen.findByText("Premiere night"); - fireEvent.click(rowTitle); + const rowTitle = await screen.findByText("Premiere night"); + fireEvent.click(rowTitle); + + await waitFor(() => { + expect(screen.getByText("Catalog Detail")).toBeInTheDocument(); + }); + expect(screen.getByText("Caption copy for the saved post.")).toBeInTheDocument(); + expect(screen.getByText("mirror-job-1")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open permalink" })).toHaveAttribute( + "href", + "https://www.instagram.com/p/source-1/", + ); + expect(screen.getByText("Facebook Crosspost")).toBeInTheDocument(); + expect(screen.getByText("742 comments from Facebook")).toBeInTheDocument(); + expect(screen.getByText(/12 saved comments out of 17 Instagram-fetchable comments/)).toBeInTheDocument(); + expect(screen.getByText(/742 Facebook accounted outside Instagram comments/)).toBeInTheDocument(); + expect(screen.getByText("Shared to Facebook")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open Facebook post" })).toHaveAttribute( + "href", + "https://www.facebook.com/bravotv/posts/fb-post-1", + ); + expect(screen.getByText("Facebook ID fb-post-1")).toBeInTheDocument(); + }); + + it("ignores stale catalog detail responses after another post is opened", async () => { + const staleDetail = deferredResponse(); + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse(baseSummary); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + if (url.includes("/catalog/posts/source-1/detail")) { + return staleDetail.promise; + } + if (url.includes("/catalog/posts/source-2/detail")) { + return jsonResponse({ + platform: "instagram", + account_handle: "bravotv", + source_id: "source-2", + title: "Current detail row", + content: "Current catalog detail should stay visible.", + permalink: "https://www.instagram.com/p/source-2/", + posted_at: "2026-04-02T12:00:00Z", + saved_metrics: { likes: 22, comments_count: 3, views: 200, engagement: 25 }, + }); + } + if (url.includes("/catalog/posts")) { + return jsonResponse({ + items: [ + { + id: "catalog-1", + source_id: "source-1", + platform: "instagram", + account_handle: "bravotv", + title: "Slow detail row", + content: "First card", + assignment_status: "assigned", + posted_at: "2026-04-01T12:00:00Z", + metrics: { likes: 11, comments_count: 2 }, + }, + { + id: "catalog-2", + source_id: "source-2", + platform: "instagram", + account_handle: "bravotv", + title: "Fast detail row", + content: "Second card", + assignment_status: "assigned", + posted_at: "2026-04-02T12:00:00Z", + metrics: { likes: 22, comments_count: 3 }, + }, + ], + pagination: { page: 1, page_size: 25, total: 2, total_pages: 1 }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + fireEvent.click(await screen.findByText("Slow detail row")); + await waitFor(() => { + expect(screen.getByText("Loading catalog post detail…")).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText("Fast detail row")); await waitFor(() => { - expect(screen.getByText("Catalog Detail")).toBeInTheDocument(); + expect(screen.getByText("Current catalog detail should stay visible.")).toBeInTheDocument(); }); - expect(screen.getByText("Caption copy for the saved post.")).toBeInTheDocument(); - expect(screen.getByText("mirror-job-1")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open permalink" })).toHaveAttribute( - "href", - "https://www.instagram.com/p/source-1/", - ); - expect(screen.getByText("Facebook Crosspost")).toBeInTheDocument(); - expect(screen.getByText("742 comments from Facebook")).toBeInTheDocument(); - expect(screen.getByText(/12 saved comments out of 17 Instagram-fetchable comments/)).toBeInTheDocument(); - expect(screen.getByText(/742 Facebook accounted outside Instagram comments/)).toBeInTheDocument(); - expect(screen.getByText("Shared to Facebook")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open Facebook post" })).toHaveAttribute( - "href", - "https://www.facebook.com/bravotv/posts/fb-post-1", - ); - expect(screen.getByText("Facebook ID fb-post-1")).toBeInTheDocument(); + + await act(async () => { + staleDetail.resolve( + jsonResponse({ + platform: "instagram", + account_handle: "bravotv", + source_id: "source-1", + title: "Stale detail row", + content: "Stale catalog detail must not replace the current modal.", + permalink: "https://www.instagram.com/p/source-1/", + posted_at: "2026-04-01T12:00:00Z", + saved_metrics: { likes: 11, comments_count: 2, views: 100, engagement: 13 }, + }), + ); + }); + + expect(screen.getByText("Current catalog detail should stay visible.")).toBeInTheDocument(); + expect(screen.queryByText("Stale catalog detail must not replace the current modal.")).not.toBeInTheDocument(); }); it("renders catalog detail media from media_urls and thumbnail-only assets", async () => { @@ -5452,7 +6391,7 @@ describe("SocialAccountProfilePage", () => { expect(screen.queryByText("Checking cookies…")).not.toBeInTheDocument(); }); - it("does not auto-repair Instagram auth before starting backfill", async () => { + it("starts Instagram backfill without auto-repairing local auth first", async () => { let cookieHealthChecks = 0; const backfillBodies: unknown[] = []; @@ -5499,17 +6438,13 @@ describe("SocialAccountProfilePage", () => { fireEvent.click(await screen.findByRole("button", { name: "Start Backfill" })); await waitFor(() => { - expect( - screen.getByText("Backfill was not started. Complete Manual Instagram Auth and confirm before rerunning Backfill."), - ).toBeInTheDocument(); + expect(backfillBodies).toHaveLength(1); }); - expect(backfillBodies).toEqual([]); - expect( - screen.getByText(/Manual Instagram auth can surface CAPTCHA, verification code, checkpoint, or account-lock prompts/i), - ).toBeInTheDocument(); + expect(screen.queryByText(/Backfill was not started/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Manual Instagram auth can surface CAPTCHA/i)).not.toBeInTheDocument(); }); - it("does not start backfill when Instagram auth is unhealthy during pre-launch gating", async () => { + it("starts Instagram backfill when local auth is unhealthy during pre-launch checks", async () => { let backfillCalled = false; mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { @@ -5547,14 +6482,12 @@ describe("SocialAccountProfilePage", () => { fireEvent.click(await screen.findByRole("button", { name: "Start Backfill" })); await waitFor(() => { - expect( - screen.getByText("Backfill was not started. Complete Manual Instagram Auth and confirm before rerunning Backfill."), - ).toBeInTheDocument(); + expect(backfillCalled).toBe(true); }); - expect(backfillCalled).toBe(false); + expect(screen.queryByText(/Backfill was not started/i)).not.toBeInTheDocument(); }); - it("blocks Instagram backfill on non-local runtimes until manual auth is completed locally", async () => { + it("starts Instagram backfill on non-local runtimes without local manual-auth gating", async () => { let backfillCalled = false; mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { @@ -5589,11 +6522,9 @@ describe("SocialAccountProfilePage", () => { fireEvent.click(await screen.findByRole("button", { name: "Start Backfill" })); await waitFor(() => { - expect( - screen.getByText("Backfill cannot continue until Manual Instagram Auth is completed from a local TRR-Backend host."), - ).toBeInTheDocument(); + expect(backfillCalled).toBe(true); }); - expect(backfillCalled).toBe(false); + expect(screen.queryByText(/Manual Instagram Auth is completed from a local TRR-Backend host/i)).not.toBeInTheDocument(); }); it("surfaces structured backfill startup errors from the proxy", async () => { @@ -6827,6 +7758,97 @@ describe("SocialAccountProfilePage", () => { expect(screen.queryByRole("heading", { name: "Collaborators / Tags", level: 2 })).not.toBeInTheDocument(); }); + it("debounces caption search and aborts a stale in-flight search", async () => { + let firstSearchSignal: AbortSignal | null = null; + + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + platform: "instagram", + }); + } + if (url.includes("/posts?page=1&page_size=25&search=old")) { + firstSearchSignal = init?.signal ?? null; + return new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + resolve( + jsonResponse({ + items: [ + { + id: "old-post", + source_id: "old", + platform: "instagram", + account_handle: "bravotv", + title: "Old caption", + content: "This stale result should never render.", + posted_at: "2026-03-18T12:00:00.000Z", + metrics: {}, + }, + ], + pagination: { page: 1, page_size: 25, total: 1, total_pages: 1 }, + }), + ); + }, 1_000); + init?.signal?.addEventListener("abort", () => { + window.clearTimeout(timeoutId); + const abortError = new Error("caption search aborted"); + abortError.name = "AbortError"; + reject(abortError); + }); + }); + } + if (url.includes("/posts?page=1&page_size=25&search=new")) { + return jsonResponse({ + items: [ + { + id: "new-post", + source_id: "new", + platform: "instagram", + account_handle: "bravotv", + title: "New caption", + content: "This is the current search result.", + posted_at: "2026-03-19T12:00:00.000Z", + metrics: {}, + }, + ], + pagination: { page: 1, page_size: 25, total: 1, total_pages: 1 }, + }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Open caption search" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Open caption search" })); + const searchbox = await screen.findByRole("searchbox", { name: "Search captions" }); + vi.useFakeTimers(); + fireEvent.change(searchbox, { target: { value: "old" } }); + + act(() => { + vi.advanceTimersByTime(250); + }); + await Promise.resolve(); + expect(firstSearchSignal).not.toBeNull(); + + fireEvent.change(searchbox, { target: { value: "new" } }); + expect(firstSearchSignal?.aborted).toBe(true); + + act(() => { + vi.advanceTimersByTime(250); + }); + await Promise.resolve(); + vi.useRealTimers(); + + expect(await screen.findByText("New caption")).toBeInTheDocument(); + expect(screen.queryByText("Old caption")).not.toBeInTheDocument(); + }, 10_000); + it("shows the caption search control beside the tab pills before any tab change", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); @@ -7266,7 +8288,7 @@ describe("SocialAccountProfilePage", () => { ); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 3_900)); + await new Promise((resolve) => setTimeout(resolve, 3_500)); }); expect(progressCalls).toBe(2); @@ -10256,7 +11278,9 @@ it("prefers terminal cancelled status labels over stale recovering state", async rerender(); await waitFor(() => { - expect(screen.getByText("Summary read timed out before completion. Retry in a moment.")).toBeInTheDocument(); + expect( + screen.getByText("Account summary temporarily unavailable. Saved profile totals may be stale until the summary reloads."), + ).toBeInTheDocument(); }); expect(screen.queryByRole("heading", { name: "Source Status" })).not.toBeInTheDocument(); expect(screen.queryByText("Bravo network source")).not.toBeInTheDocument(); @@ -11784,9 +12808,6 @@ it("renders blocked-auth manual-auth controls and starts the sync flow", async ( "Instagram blocked this catalog run before jobs were queued. Complete manual auth first, then sync already validated cookies.", ), ).toBeInTheDocument(); - expect( - screen.getByText(/Instagram backfill blocked before jobs were queued\. Local cookies are present/i), - ).toBeInTheDocument(); expect(screen.getByText("Instagram posts auth blocked")).toBeInTheDocument(); }); expect(screen.queryByRole("button", { name: "Retry Locally" })).not.toBeInTheDocument(); @@ -12710,6 +13731,164 @@ it("uses the newest inspected catalog run from the summary when discovery outran expect(gapStatusPollCount).toBeGreaterThanOrEqual(1); }); + it("ignores stale gap-analysis polls before rendering the follow-up repair action", async () => { + let gapStatusPollCount = 0; + let syncNewerCalled = false; + let backfillCalled = false; + + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + total_posts: 20, + live_total_posts: 20, + catalog_total_posts: 12, + live_catalog_total_posts: 12, + catalog_recent_runs: [ + { + run_id: "run-gap-stale-poll-1", + status: "completed", + created_at: "2026-03-20T12:00:00.000Z", + }, + ], + }); + } + if (url.includes("/catalog/freshness")) { + expect(init?.method).toBe("POST"); + return jsonResponse({ + platform: "instagram", + account_handle: "bravotv", + eligible: true, + live_total_posts_current: 20, + stored_total_posts: 12, + delta_posts: 8, + needs_recent_sync: true, + checked_at: "2026-03-20T12:30:00.000Z", + }); + } + if (url.includes("/catalog/gap-analysis/run")) { + expect(init?.method).toBe("POST"); + return jsonResponse({ + platform: "instagram", + account_handle: "bravotv", + status: "queued", + operation_id: "gap-op-current", + result: null, + stale: false, + }); + } + if (url.includes("/catalog/gap-analysis")) { + gapStatusPollCount += 1; + if (gapStatusPollCount === 1) { + return jsonResponse({ + platform: "instagram", + account_handle: "bravotv", + status: "completed", + operation_id: "gap-op-stale", + stale: true, + result: { + platform: "instagram", + account_handle: "bravotv", + gap_type: "tail_gap", + catalog_posts: 12, + materialized_posts: 20, + expected_total_posts: 20, + live_total_posts_current: 20, + missing_from_catalog_count: 8, + sample_missing_source_ids: ["old-post"], + has_resumable_frontier: true, + needs_recent_sync: false, + recommended_action: "backfill_posts", + latest_catalog_run_status: "completed", + active_run_status: null, + }, + }); + } + return jsonResponse({ + platform: "instagram", + account_handle: "bravotv", + status: "completed", + operation_id: "gap-op-current", + stale: false, + result: { + platform: "instagram", + account_handle: "bravotv", + gap_type: "head_gap", + catalog_posts: 12, + materialized_posts: 20, + expected_total_posts: 20, + live_total_posts_current: 20, + missing_from_catalog_count: 8, + sample_missing_source_ids: ["new-post"], + has_resumable_frontier: false, + needs_recent_sync: true, + recommended_action: "sync_newer", + latest_catalog_run_status: "completed", + active_run_status: null, + }, + }); + } + if (url.includes("/catalog/sync-newer")) { + syncNewerCalled = true; + return jsonResponse({ + run_id: "catalog-run-current-12345678", + status: "queued", + catalog_action: "sync_newer", + catalog_action_scope: "head_gap", + }); + } + if (url.includes("/cookies/health")) { + return jsonResponse(healthyCookieHealth("instagram")); + } + if (url.includes("/catalog/backfill")) { + backfillCalled = true; + return jsonResponse({ run_id: "catalog-run-stale-12345678", status: "queued" }); + } + if (url.includes("/catalog/posts")) { + return jsonResponse({ items: [], pagination: { page: 1, page_size: 25, total: 0, total_pages: 1 } }); + } + if (url.includes("/catalog/review-queue")) { + return jsonResponse({ items: [] }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Run Gap Analysis" })).toBeInTheDocument(); + }); + vi.useFakeTimers(); + fireEvent.click(screen.getByRole("button", { name: "Run Gap Analysis" })); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + vi.advanceTimersByTime(4_000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(gapStatusPollCount).toBe(1); + expect(screen.queryByRole("button", { name: "Backfill Posts Now" })).not.toBeInTheDocument(); + + await act(async () => { + vi.advanceTimersByTime(4_000); + await Promise.resolve(); + await Promise.resolve(); + }); + vi.useRealTimers(); + + expect(screen.getByRole("button", { name: "Sync Newer Now" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Sync Newer Now" })); + + await waitFor(() => { + expect(syncNewerCalled).toBe(true); + }); + expect(backfillCalled).toBe(false); + }, 10_000); + it("groups catalog diagnostic failures without rendering duplicate raw backend banners", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -12803,6 +13982,42 @@ it("uses the newest inspected catalog run from the summary when discovery outran expect(screen.queryByText("TRR-Backend request timed out.")).not.toBeInTheDocument(); }); + it("scopes catalog post timeouts to the gallery instead of showing the raw backend timeout", async () => { + mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/summary")) { + return jsonResponse({ + ...baseSummary, + catalog_recent_runs: [], + }); + } + if (url.includes("/catalog/posts")) { + return jsonResponse( + { + error: "TRR-Backend request timed out.", + code: "UPSTREAM_TIMEOUT", + retryable: true, + upstream_status: 504, + }, + 504, + ); + } + if (url.includes("/catalog/review-queue")) { + return jsonResponse({ items: [] }); + } + throw new Error(`Unhandled request: ${url}`); + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText("Catalog gallery temporarily unavailable. Saved post cards will reload when the gallery request succeeds."), + ).toBeInTheDocument(); + }); + expect(screen.queryByText("TRR-Backend request timed out.")).not.toBeInTheDocument(); + }); + it("renders the catalog tab empty state", async () => { mocks.fetchAdminWithAuth.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); diff --git a/apps/web/tests/social-account-profile-route.test.ts b/apps/web/tests/social-account-profile-route.test.ts index f0e9012e..6e85e7d0 100644 --- a/apps/web/tests/social-account-profile-route.test.ts +++ b/apps/web/tests/social-account-profile-route.test.ts @@ -78,6 +78,20 @@ describe("social account profile stats page", () => { expect(element.props.activeTab).toBe("catalog"); }); + it("keeps the canonical posts route on the shared profile component policy", async () => { + const page = await import("@/app/social/[platform]/[handle]/posts/page"); + const element = await page.default({ + params: Promise.resolve({ + platform: "instagram", + handle: "bravotv", + }), + }); + + expect(element.props.platform).toBe("instagram"); + expect(element.props.handle).toBe("bravotv"); + expect(element.props.activeTab).toBe("posts"); + }); + it("rejects unsupported public comments platforms", async () => { const page = await import("@/app/social/[platform]/[handle]/comments/page"); await expect( @@ -138,6 +152,18 @@ describe("social account profile stats page", () => { ).rejects.toThrow("NEXT_REDIRECT:/social/instagram/bravotv/catalog"); }); + it("redirects legacy admin posts routes to the canonical public path", async () => { + const page = await import("@/app/admin/social/[platform]/[handle]/posts/page"); + await expect( + page.default({ + params: Promise.resolve({ + platform: "Instagram", + handle: "@BravoTV", + }), + }), + ).rejects.toThrow("NEXT_REDIRECT:/social/instagram/bravotv/posts"); + }); + it("rejects unsupported legacy admin comments platforms", async () => { const page = await import("@/app/admin/social/[platform]/[handle]/comments/page"); await expect( diff --git a/apps/web/tests/social-account-profile-snapshot-route.test.ts b/apps/web/tests/social-account-profile-snapshot-route.test.ts index e3a65341..3ea9f447 100644 --- a/apps/web/tests/social-account-profile-snapshot-route.test.ts +++ b/apps/web/tests/social-account-profile-snapshot-route.test.ts @@ -58,6 +58,7 @@ vi.mock("@/lib/server/trr-api/social-admin-proxy", () => ({ fetchSocialBackendJson: fetchSocialBackendJsonMock, SOCIAL_PROXY_DEFAULT_TIMEOUT_MS: 25_000, SOCIAL_PROXY_LONG_TIMEOUT_MS: 30_000, + SOCIAL_PROXY_PROGRESS_TIMEOUT_MS: 30_000, socialProxyErrorResponse: socialProxyErrorResponseMock, })); @@ -170,7 +171,7 @@ describe("social account profile snapshot route", () => { expect.objectContaining({ queryString: "fast=1&recent_log_limit=25", retries: 0, - timeoutMs: 12_000, + timeoutMs: 30_000, }), ); }); @@ -208,7 +209,7 @@ describe("social account profile snapshot route", () => { fallbackError: "Failed to fetch social account catalog run progress", queryString: "fast=1&recent_log_limit=12", retries: 0, - timeoutMs: 12_000, + timeoutMs: 30_000, }), ); expect(getOrCreateAdminSnapshotMock).not.toHaveBeenCalled(); @@ -298,73 +299,40 @@ describe("social account profile snapshot route", () => { ); }); - it("bypasses cached social profile snapshots while comments coverage has an active run", async () => { + it("serves cached social profile snapshots while comments coverage has an active run", async () => { const expectedInfo = captureExpectedConsoleInfo(/^social_profile_dashboard_budget/); - getOrCreateAdminSnapshotMock - .mockResolvedValueOnce({ - data: { - summary: makeSocialAccountSummary({ - comments_saved_summary: { - saved_comments: 96564, - retrieved_comments: 106592, - }, - comments_coverage: { - eligible_posts: 437, - missing_posts: 12, - stale_posts: 203, - active_run_id: "comments-run-active", - effective_status: "running", - last_comments_run_status: "running", - }, - }), - catalog_run_progress: null, - dashboard_freshness: { - status: "fresh", - source: "live", - generated_at: "2026-04-26T12:00:00.000Z", - age_seconds: 0, + getOrCreateAdminSnapshotMock.mockResolvedValueOnce({ + data: { + summary: makeSocialAccountSummary({ + comments_saved_summary: { + saved_comments: 96564, + retrieved_comments: 106592, }, - operational_alerts: [], - }, - meta: { - cacheStatus: "hit", - generatedAt: "2026-04-26T12:00:00.000Z", - cacheAgeMs: 120_000, - stale: false, - }, - }) - .mockResolvedValueOnce({ - data: { - summary: makeSocialAccountSummary({ - comments_saved_summary: { - saved_comments: 98409, - retrieved_comments: 106212, - }, - comments_coverage: { - eligible_posts: 437, - missing_posts: 12, - stale_posts: 203, - active_run_id: "comments-run-active", - effective_status: "running", - last_comments_run_status: "running", - }, - }), - catalog_run_progress: null, - dashboard_freshness: { - status: "fresh", - source: "live", - generated_at: "2026-04-26T12:02:00.000Z", - age_seconds: 0, + comments_coverage: { + eligible_posts: 437, + missing_posts: 12, + stale_posts: 203, + active_run_id: "comments-run-active", + effective_status: "running", + last_comments_run_status: "running", }, - operational_alerts: [], - }, - meta: { - cacheStatus: "refresh", - generatedAt: "2026-04-26T12:02:00.000Z", - cacheAgeMs: 0, - stale: false, + }), + catalog_run_progress: null, + dashboard_freshness: { + status: "fresh", + source: "live", + generated_at: "2026-04-26T12:00:00.000Z", + age_seconds: 0, }, - }); + operational_alerts: [], + }, + meta: { + cacheStatus: "hit", + generatedAt: "2026-04-26T12:00:00.000Z", + cacheAgeMs: 120_000, + stale: false, + }, + }); const response = await GET( new NextRequest("http://localhost/api/admin/trr-api/social/profiles/instagram/thetraitorsus/snapshot?detail=lite"), @@ -375,15 +343,9 @@ describe("social account profile snapshot route", () => { }; expect(response.status).toBe(200); - expect(body.data.summary?.comments_saved_summary?.saved_comments).toBe(98409); - expect(response.headers.get("x-trr-cache")).toBe("refresh"); - expect(getOrCreateAdminSnapshotMock).toHaveBeenCalledTimes(2); - expect(getOrCreateAdminSnapshotMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - forceRefresh: true, - }), - ); + expect(body.data.summary?.comments_saved_summary?.saved_comments).toBe(96564); + expect(response.headers.get("x-trr-cache")).toBe("hit"); + expect(getOrCreateAdminSnapshotMock).toHaveBeenCalledTimes(1); expectedInfo.expectCalled(); }); }); diff --git a/apps/web/tests/social-growth-cookie-health-route.test.ts b/apps/web/tests/social-growth-cookie-health-route.test.ts new file mode 100644 index 00000000..32a834d6 --- /dev/null +++ b/apps/web/tests/social-growth-cookie-health-route.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { + requireAdminMock, + getBackendApiUrlMock, + buildInternalAdminHeadersMock, + timeoutSafeFetchMock, + isTimeoutSafeFetchTimeoutErrorMock, +} = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + getBackendApiUrlMock: vi.fn(), + buildInternalAdminHeadersMock: vi.fn(), + timeoutSafeFetchMock: vi.fn(), + isTimeoutSafeFetchTimeoutErrorMock: vi.fn(), +})); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, +})); + +vi.mock("@/lib/server/trr-api/backend", () => ({ + getBackendApiUrl: getBackendApiUrlMock, +})); + +vi.mock("@/lib/server/trr-api/internal-admin-auth", () => ({ + buildInternalAdminHeaders: buildInternalAdminHeadersMock, +})); + +vi.mock("@/lib/server/timeout-safe-fetch", () => ({ + timeoutSafeFetch: timeoutSafeFetchMock, + isTimeoutSafeFetchTimeoutError: isTimeoutSafeFetchTimeoutErrorMock, +})); + +import { GET } from "@/app/api/admin/trr-api/social-growth/cookies/health/route"; + +describe("social growth cookie health route", () => { + beforeEach(() => { + requireAdminMock.mockReset(); + getBackendApiUrlMock.mockReset(); + buildInternalAdminHeadersMock.mockReset(); + timeoutSafeFetchMock.mockReset(); + isTimeoutSafeFetchTimeoutErrorMock.mockReset(); + + requireAdminMock.mockResolvedValue(undefined); + getBackendApiUrlMock.mockReturnValue("https://backend.example.com/api/v1/admin/people/socialblade/cookies/health?validate=true&handle=bravotv"); + buildInternalAdminHeadersMock.mockReturnValue(new Headers({ authorization: "Bearer internal-admin" })); + isTimeoutSafeFetchTimeoutErrorMock.mockReturnValue(false); + }); + + it("forwards query params to the backend with internal admin auth", async () => { + timeoutSafeFetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + healthy: true, + platform: "socialblade", + validation: { checked: true, healthy: true, handle: "bravotv" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const response = await GET( + new NextRequest("http://localhost/api/admin/trr-api/social-growth/cookies/health?validate=true&handle=bravotv"), + ); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + healthy: true, + platform: "socialblade", + validation: { handle: "bravotv" }, + }); + expect(getBackendApiUrlMock).toHaveBeenCalledWith( + "/admin/people/socialblade/cookies/health?validate=true&handle=bravotv", + ); + expect(timeoutSafeFetchMock).toHaveBeenCalledWith( + "https://backend.example.com/api/v1/admin/people/socialblade/cookies/health?validate=true&handle=bravotv", + expect.objectContaining({ + headers: expect.any(Headers), + timeoutMs: 50_000, + timeoutName: "socialblade-cookie-health", + }), + ); + const [, init] = timeoutSafeFetchMock.mock.calls[0]; + expect((init.headers as Headers).get("authorization")).toBe("Bearer internal-admin"); + }); + + it("preserves SocialBlade backend error metadata", async () => { + timeoutSafeFetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + detail: { + message: "SocialBlade session preflight failed before batch dispatch: missing_required_cookie:session", + code: "SOCIALBLADE_SESSION_PREFLIGHT_FAILED", + reason: "missing_required_cookie:session", + retryable: true, + }, + }), + { status: 409, headers: { "content-type": "application/json" } }, + ), + ); + + const response = await GET(new NextRequest("http://localhost/api/admin/trr-api/social-growth/cookies/health")); + const payload = await response.json(); + + expect(response.status).toBe(409); + expect(payload).toMatchObject({ + error: "SocialBlade session preflight failed before batch dispatch: missing_required_cookie:session", + code: "SOCIALBLADE_SESSION_PREFLIGHT_FAILED", + reason: "missing_required_cookie:session", + retryable: true, + }); + }); +}); diff --git a/apps/web/tests/social-ingest-media-mirror-drain-account-route.test.ts b/apps/web/tests/social-ingest-media-mirror-drain-account-route.test.ts new file mode 100644 index 00000000..260e99c2 --- /dev/null +++ b/apps/web/tests/social-ingest-media-mirror-drain-account-route.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { requireAdminMock, fetchSocialBackendJsonMock, socialProxyErrorResponseMock } = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + fetchSocialBackendJsonMock: vi.fn(), + socialProxyErrorResponseMock: vi.fn(), +})); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, +})); + +vi.mock("@/lib/server/trr-api/social-admin-proxy", () => ({ + fetchSocialBackendJson: fetchSocialBackendJsonMock, + socialProxyErrorResponse: socialProxyErrorResponseMock, + SOCIAL_PROXY_DEFAULT_TIMEOUT_MS: 45_000, +})); + +import { POST } from "@/app/api/admin/trr-api/social/ingest/media-mirror/drain-account/route"; + +describe("social ingest media mirror account drain proxy route", () => { + beforeEach(() => { + requireAdminMock.mockReset(); + fetchSocialBackendJsonMock.mockReset(); + socialProxyErrorResponseMock.mockReset(); + + requireAdminMock.mockResolvedValue(undefined); + fetchSocialBackendJsonMock.mockResolvedValue({ + ok: true, + before_remaining: 25, + recovered: 25, + dispatched: 8, + after_remaining: 17, + stop_reason: "dispatch_limit_reached", + next_recommended_action: "run again", + }); + socialProxyErrorResponseMock.mockImplementation((error: unknown) => + new Response(JSON.stringify({ error: String(error), code: "BACKEND_UNREACHABLE" }), { + status: 502, + headers: { "content-type": "application/json" }, + }), + ); + }); + + it("forwards run-scoped account drain payload to backend", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/drain-account", + { + method: "POST", + body: JSON.stringify({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + account_handle: "@bravotv", + stage: "all", + confirm_drain: "DRAIN BRAVO MEDIA", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + expect(response.status).toBe(200); + expect(fetchSocialBackendJsonMock).toHaveBeenCalledWith( + "/ingest/media-mirror/drain-account", + expect.objectContaining({ + method: "POST", + fallbackError: "Failed to drain Bravo media mirror jobs", + retries: 0, + timeoutMs: 45_000, + }), + ); + const body = JSON.parse(String(fetchSocialBackendJsonMock.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + account_handle: "bravotv", + stage: "all", + recover_limit: 25, + dispatch_limit: 8, + confirm_drain: "DRAIN BRAVO MEDIA", + }); + }); + + it("rejects invalid run IDs before backend fetch", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/drain-account", + { + method: "POST", + body: JSON.stringify({ + run_id: "bad-run", + account_handle: "bravotv", + confirm_drain: "DRAIN BRAVO MEDIA", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + const payload = (await response.json()) as { code?: string }; + expect(response.status).toBe(400); + expect(payload.code).toBe("BAD_REQUEST"); + expect(fetchSocialBackendJsonMock).not.toHaveBeenCalled(); + }); + + it("rejects missing account handles before backend fetch", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/drain-account", + { + method: "POST", + body: JSON.stringify({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + account_handle: "", + confirm_drain: "DRAIN BRAVO MEDIA", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + const payload = (await response.json()) as { code?: string }; + expect(response.status).toBe(400); + expect(payload.code).toBe("BAD_REQUEST"); + expect(fetchSocialBackendJsonMock).not.toHaveBeenCalled(); + }); + + it("rejects invalid stages before backend fetch", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/drain-account", + { + method: "POST", + body: JSON.stringify({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + account_handle: "bravotv", + stage: "profile", + confirm_drain: "DRAIN BRAVO MEDIA", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + const payload = (await response.json()) as { code?: string }; + expect(response.status).toBe(400); + expect(payload.code).toBe("BAD_REQUEST"); + expect(fetchSocialBackendJsonMock).not.toHaveBeenCalled(); + }); + + it("rejects missing confirmation before backend fetch", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/drain-account", + { + method: "POST", + body: JSON.stringify({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + account_handle: "bravotv", + confirm_drain: "wrong", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + const payload = (await response.json()) as { code?: string }; + expect(response.status).toBe(400); + expect(payload.code).toBe("BAD_REQUEST"); + expect(fetchSocialBackendJsonMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/social-ingest-media-mirror-recover-route.test.ts b/apps/web/tests/social-ingest-media-mirror-recover-route.test.ts new file mode 100644 index 00000000..f96ad1f1 --- /dev/null +++ b/apps/web/tests/social-ingest-media-mirror-recover-route.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { requireAdminMock, fetchSocialBackendJsonMock, socialProxyErrorResponseMock } = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), + fetchSocialBackendJsonMock: vi.fn(), + socialProxyErrorResponseMock: vi.fn(), +})); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, +})); + +vi.mock("@/lib/server/trr-api/social-admin-proxy", () => ({ + fetchSocialBackendJson: fetchSocialBackendJsonMock, + socialProxyErrorResponse: socialProxyErrorResponseMock, + SOCIAL_PROXY_DEFAULT_TIMEOUT_MS: 45_000, +})); + +import { POST } from "@/app/api/admin/trr-api/social/ingest/media-mirror/recover-stale/route"; + +describe("social ingest media mirror stale recovery proxy route", () => { + beforeEach(() => { + requireAdminMock.mockReset(); + fetchSocialBackendJsonMock.mockReset(); + socialProxyErrorResponseMock.mockReset(); + + requireAdminMock.mockResolvedValue(undefined); + fetchSocialBackendJsonMock.mockResolvedValue({ + ok: true, + recovered_count: 1, + recovered_job_ids: ["job-1"], + dispatch: { dispatched_job_ids: ["job-2"], dispatch_attempts: 1 }, + }); + socialProxyErrorResponseMock.mockImplementation((error: unknown) => + new Response(JSON.stringify({ error: String(error), code: "BACKEND_UNREACHABLE" }), { + status: 502, + headers: { "content-type": "application/json" }, + }), + ); + }); + + it("forwards run-scoped media recovery payload to backend", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/recover-stale", + { + method: "POST", + body: JSON.stringify({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + stage: "all", + confirm_recovery: "RECOVER MEDIA MIRROR JOBS", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + expect(response.status).toBe(200); + expect(fetchSocialBackendJsonMock).toHaveBeenCalledWith( + "/ingest/media-mirror/recover-stale", + expect.objectContaining({ + method: "POST", + fallbackError: "Failed to recover stale media mirror jobs", + retries: 0, + timeoutMs: 45_000, + }), + ); + const body = JSON.parse(String(fetchSocialBackendJsonMock.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + stage: "all", + confirm_recovery: "RECOVER MEDIA MIRROR JOBS", + }); + }); + + it("rejects invalid run IDs before backend fetch", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/recover-stale", + { + method: "POST", + body: JSON.stringify({ + run_id: "bad-run", + confirm_recovery: "RECOVER MEDIA MIRROR JOBS", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + const payload = (await response.json()) as { code?: string }; + expect(response.status).toBe(400); + expect(payload.code).toBe("BAD_REQUEST"); + expect(fetchSocialBackendJsonMock).not.toHaveBeenCalled(); + }); + + it("rejects missing confirmation before backend fetch", async () => { + const request = new NextRequest( + "http://localhost/api/admin/trr-api/social/ingest/media-mirror/recover-stale", + { + method: "POST", + body: JSON.stringify({ + run_id: "77f85ad9-0b32-4607-8ff4-999261bab84c", + confirm_recovery: "wrong", + }), + headers: { "content-type": "application/json" }, + }, + ); + + const response = await POST(request); + const payload = (await response.json()) as { code?: string }; + expect(response.status).toBe(400); + expect(payload.code).toBe("BAD_REQUEST"); + expect(fetchSocialBackendJsonMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/social-media-queue-snapshots-route.test.ts b/apps/web/tests/social-media-queue-snapshots-route.test.ts new file mode 100644 index 00000000..31228354 --- /dev/null +++ b/apps/web/tests/social-media-queue-snapshots-route.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const { requireAdminMock } = vi.hoisted(() => ({ + requireAdminMock: vi.fn(), +})); + +vi.mock("@/lib/server/auth", () => ({ + requireAdmin: requireAdminMock, +})); + +import { GET } from "@/app/api/admin/social/media-queue/snapshots/route"; + +describe("social media queue snapshot route", () => { + let tempRoot: string; + const originalCwd = process.cwd(); + + beforeEach(() => { + requireAdminMock.mockReset(); + requireAdminMock.mockResolvedValue(undefined); + if (tempRoot) { + rmSync(tempRoot, { force: true, recursive: true }); + } + tempRoot = mkdtempSync(path.join(tmpdir(), "trr-media-snapshots-")); + const appCwd = path.join(tempRoot, "TRR-APP", "apps", "web"); + vi.spyOn(process, "cwd").mockReturnValue(appCwd); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.chdir(originalCwd); + if (tempRoot) { + rmSync(tempRoot, { force: true, recursive: true }); + } + }); + + it("lists safe timestamped queue snapshot links", async () => { + const snapshotDir = path.join(tempRoot, ".logs", "workspace", "social-queue-snapshots"); + await import("node:fs/promises").then((fs) => fs.mkdir(snapshotDir, { recursive: true })); + writeFileSync( + path.join(snapshotDir, "20260622T143000Z-77f85ad9-0b32-4607-8ff4-999261bab84c-media_mirror.json"), + JSON.stringify({ ok: true }), + ); + writeFileSync(path.join(snapshotDir, "unsafe.txt"), "skip"); + + const response = await GET( + new NextRequest("http://localhost/api/admin/social/media-queue/snapshots"), + ); + const payload = (await response.json()) as { + snapshots?: Array<{ name: string; href: string; runId: string; stage: string }>; + }; + + expect(response.status).toBe(200); + expect(payload.snapshots).toHaveLength(1); + expect(payload.snapshots?.[0]).toMatchObject({ + name: "20260622T143000Z-77f85ad9-0b32-4607-8ff4-999261bab84c-media_mirror.json", + href: "/api/admin/social/media-queue/snapshots?file=20260622T143000Z-77f85ad9-0b32-4607-8ff4-999261bab84c-media_mirror.json", + createdAt: "2026-06-22T14:30:00Z", + runId: "77f85ad9-0b32-4607-8ff4-999261bab84c", + stage: "media_mirror", + }); + }); + + it("does not read unsafe file names", async () => { + const response = await GET( + new NextRequest("http://localhost/api/admin/social/media-queue/snapshots?file=../../package.json"), + ); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(payload.error).toBe("Invalid snapshot file name"); + }); +}); diff --git a/apps/web/tests/social-timezone.test.ts b/apps/web/tests/social-timezone.test.ts new file mode 100644 index 00000000..ef7c260c --- /dev/null +++ b/apps/web/tests/social-timezone.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { buildIsoDayRange, SOCIAL_TIME_ZONE } from "@/lib/admin/social-timezone"; + +describe("social timezone buckets", () => { + it("builds New York day buckets across standard time", () => { + expect(SOCIAL_TIME_ZONE).toBe("America/New_York"); + expect(buildIsoDayRange("2026-01-01")).toEqual({ + dateStart: "2026-01-01T05:00:00.000Z", + dateEnd: "2026-01-02T04:59:59.999Z", + }); + }); + + it("builds New York day buckets across daylight saving time", () => { + expect(buildIsoDayRange("2026-07-01")).toEqual({ + dateStart: "2026-07-01T04:00:00.000Z", + dateEnd: "2026-07-02T03:59:59.999Z", + }); + }); + + it("builds the local day boundary when daylight saving time starts", () => { + expect(buildIsoDayRange("2026-03-08")).toEqual({ + dateStart: "2026-03-08T05:00:00.000Z", + dateEnd: "2026-03-09T03:59:59.999Z", + }); + }); + + it("builds the local day boundary when daylight saving time ends", () => { + expect(buildIsoDayRange("2026-11-01")).toEqual({ + dateStart: "2026-11-01T04:00:00.000Z", + dateEnd: "2026-11-02T04:59:59.999Z", + }); + }); + + it("rejects malformed local day tokens", () => { + expect(buildIsoDayRange("2026-7-1")).toBeNull(); + expect(buildIsoDayRange("not-a-date")).toBeNull(); + }); +}); diff --git a/apps/web/tests/trr-app-flags.test.ts b/apps/web/tests/trr-app-flags.test.ts new file mode 100644 index 00000000..723469f9 --- /dev/null +++ b/apps/web/tests/trr-app-flags.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + TRR_APP_FLAGS, + getTrrAppFlagDefinitions, + getTrrAppFlagValue, + getTrrAppFlags, + parseTrrAppFlagValue, +} from "@/lib/trr-app-flags"; + +describe("TRR app flags", () => { + it("keeps risky admin/social flags off by default", () => { + expect(getTrrAppFlagValue("adminSocialIngestionUi", {})).toBe(false); + expect(getTrrAppFlagValue("adminSocialScraperTriggers", {})).toBe(false); + expect(getTrrAppFlagValue("adminSocialLiveRefresh", {})).toBe(false); + }); + + it("keeps the existing cast/social dashboard on by default", () => { + expect(getTrrAppFlagValue("adminCastSocialDashboard", {})).toBe(true); + }); + + it("accepts explicit boolean env overrides", () => { + expect( + getTrrAppFlagValue("adminSocialIngestionUi", { + NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_INGESTION_UI: "enabled", + }), + ).toBe(true); + + expect( + getTrrAppFlagValue("adminCastSocialDashboard", { + NEXT_PUBLIC_TRR_FLAG_ADMIN_CAST_SOCIAL_DASHBOARD: "off", + }), + ).toBe(false); + }); + + it("falls back to safe defaults for missing or invalid overrides", () => { + expect(parseTrrAppFlagValue("definitely")).toBeNull(); + expect( + getTrrAppFlagValue("adminSocialScraperTriggers", { + NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_SCRAPER_TRIGGERS: "definitely", + }), + ).toBe(false); + expect( + getTrrAppFlagValue("adminCastSocialDashboard", { + NEXT_PUBLIC_TRR_FLAG_ADMIN_CAST_SOCIAL_DASHBOARD: "definitely", + }), + ).toBe(true); + }); + + it("exposes stable flag definitions and values from one module", () => { + expect(getTrrAppFlagDefinitions()).toEqual([ + TRR_APP_FLAGS.adminSocialIngestionUi, + TRR_APP_FLAGS.adminSocialScraperTriggers, + TRR_APP_FLAGS.adminSocialLiveRefresh, + TRR_APP_FLAGS.adminCastSocialDashboard, + ]); + + expect( + getTrrAppFlags({ + NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_INGESTION_UI: "true", + NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_SCRAPER_TRIGGERS: "false", + NEXT_PUBLIC_TRR_FLAG_ADMIN_SOCIAL_LIVE_REFRESH: "1", + NEXT_PUBLIC_TRR_FLAG_ADMIN_CAST_SOCIAL_DASHBOARD: "0", + }), + ).toEqual({ + adminSocialIngestionUi: true, + adminSocialScraperTriggers: false, + adminSocialLiveRefresh: true, + adminCastSocialDashboard: false, + }); + }); +}); diff --git a/package.json b/package.json index aa668f09..2076f65d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "packageManager": "pnpm@11.5.2", "dependencies": { - "firebase": "^12.14.0", - "firebase-admin": "^14.0.0" + "firebase": "^12.15.0", + "firebase-admin": "^14.1.0" }, "scripts": { "dev": "lsof -i:8000 >/dev/null 2>&1 && echo 'API already running on :8000' && pnpm run web:dev || concurrently -k -n api,web -c yellow,green \"cd ../TRR-Backend && source .venv/bin/activate && uvicorn api.main:app --reload --port 8000\" \"pnpm run web:dev\"", @@ -36,7 +36,7 @@ "private": true, "devDependencies": { "concurrently": "^10.0.3", - "firebase-tools": "^15.20.0" + "firebase-tools": "^15.22.3" }, "engines": { "node": "24.x" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46622480..164e91b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: glob: 10.4.5 json-ptr: 3.1.0 + jwks-rsa@4>jose: 5.10.0 node-domexception: 2.0.2 importers: @@ -14,18 +15,18 @@ importers: .: dependencies: firebase: - specifier: ^12.14.0 - version: 12.14.0 + specifier: ^12.15.0 + version: 12.15.0 firebase-admin: - specifier: ^14.0.0 - version: 14.0.0 + specifier: ^14.1.0 + version: 14.1.0 devDependencies: concurrently: specifier: ^10.0.3 version: 10.0.3 firebase-tools: - specifier: ^15.20.0 - version: 15.20.0(@types/node@25.9.3) + specifier: ^15.22.3 + version: 15.22.3(@types/node@26.0.1) apps/web: dependencies: @@ -39,14 +40,14 @@ importers: specifier: ^3.2.2 version: 3.2.2(react@19.2.7) '@firebase/app': - specifier: 0.14.13 - version: 0.14.13 + specifier: 0.15.0 + version: 0.15.0 '@firebase/component': specifier: 0.7.3 version: 0.7.3 '@firebase/firestore': - specifier: 4.15.0 - version: 4.15.0(@firebase/app@0.14.13) + specifier: 4.16.0 + version: 4.16.0(@firebase/app@0.15.0) '@firebase/logger': specifier: 0.5.1 version: 0.5.1 @@ -54,14 +55,20 @@ importers: specifier: 1.15.1 version: 1.15.1 '@sentry/nextjs': - specifier: ^10.58.0 - version: 10.58.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.107.2(lightningcss@1.32.0)) + specifier: ^10.62.0 + version: 10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.107.2(lightningcss@1.32.0)) '@supabase/supabase-js': - specifier: ^2.108.2 - version: 2.108.2 + specifier: ^2.110.0 + version: 2.110.0 + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@vercel/functions': - specifier: ^3.7.1 - version: 3.7.1(ws@8.21.0) + specifier: ^3.7.4 + version: 3.7.4(ws@8.21.0) + '@vercel/speed-insights': + specifier: ^2.0.0 + version: 2.0.0(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -69,29 +76,29 @@ importers: specifier: ^2.1.1 version: 2.1.1 firebase: - specifier: ^12.14.0 - version: 12.14.0 + specifier: ^12.15.0 + version: 12.15.0 firebase-admin: - specifier: ^14.0.0 - version: 14.0.0 + specifier: ^14.1.0 + version: 14.1.0 framer-motion: - specifier: ^12.40.0 - version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^12.42.1 + version: 12.42.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) html-to-image: specifier: ^1.11.13 version: 1.11.13 lucide-react: - specifier: ^1.18.0 - version: 1.18.0(react@19.2.7) + specifier: ^1.22.0 + version: 1.22.0(react@19.2.7) next: specifier: 16.2.9 - version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) pg: - specifier: ^8.21.0 - version: 8.21.0 + specifier: ^8.22.0 + version: 8.22.0 radix-ui: - specifier: ^1.5.0 - version: 1.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^1.6.0 + version: 1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -99,8 +106,8 @@ importers: specifier: 19.2.7 version: 19.2.7(react@19.2.7) shadcn: - specifier: ^4.11.0 - version: 4.11.0(typescript@5.9.3) + specifier: ^4.12.0 + version: 4.12.0(typescript@5.9.3) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -108,18 +115,18 @@ importers: specifier: ^1.4.0 version: 1.4.0 undici: - specifier: ^8.4.1 - version: 8.4.1 + specifier: ^8.5.0 + version: 8.5.0 devDependencies: '@eslint/eslintrc': specifier: ^3.3.5 version: 3.3.5 '@playwright/test': - specifier: ^1.61.0 - version: 1.61.0 + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': - specifier: ^4.3.1 - version: 4.3.1 + specifier: ^4.3.2 + version: 4.3.2 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -127,8 +134,8 @@ importers: specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/node': - specifier: ^25.9.3 - version: 25.9.3 + specifier: ^25.9.4 + version: 25.9.4 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -149,28 +156,28 @@ importers: version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: 16.2.9 - version: 16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + version: 16.2.9(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@1.8.0) tailwindcss: - specifier: ^4.3.1 - version: 4.3.1 + specifier: ^4.3.2 + version: 4.3.2 typescript: specifier: ^5.9.3 version: 5.9.3 vite: - specifier: ^7.3.5 - version: 7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + specifier: ^7.3.6 + version: 7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) vite-tsconfig-paths: specifier: ^6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vitest: specifier: ^4.1.9 - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) vitest-axe: specifier: ^0.1.0 - version: 0.1.0(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))) + version: 0.1.0(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))) yaml: specifier: ^2.9.0 version: 2.9.0 @@ -187,6 +194,17 @@ packages: '@apidevtools/json-schema-ref-parser@9.1.2': resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} + '@apm-js-collab/code-transformer-bundler-plugins@0.5.0': + resolution: {integrity: sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==} + engines: {node: '>=18.0.0'} + + '@apm-js-collab/code-transformer@0.15.0': + resolution: {integrity: sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==} + hasBin: true + + '@apm-js-collab/tracing-hooks@0.10.0': + resolution: {integrity: sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA==} + '@apphosting/common@0.0.8': resolution: {integrity: sha512-RJu5gXs2HYV7+anxpVPpp04oXeuHbV3qn402AdXVlnuYM/uWo7aceqmngpfp6Bi376UzRqGjfpdwFHxuwsEGXQ==} @@ -361,8 +379,8 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.1.3': - resolution: {integrity: sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==} + '@csstools/css-color-parser@4.1.8': + resolution: {integrity: sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==} engines: {node: '>=20.19.0'} peerDependencies: '@csstools/css-parser-algorithms': ^4.0.0 @@ -411,15 +429,12 @@ packages: peerDependencies: react: '>=16.8.0' - '@dotenvx/dotenvx@1.71.3': - resolution: {integrity: sha512-WSmox5aD+XxJEUEOTk7gKLpd5+Iz9Nik89Zpbu5DijMln6LsFiv3xpNKBMc/b9sSkUlKvAblzrhik2TqKFE7NA==} + '@dotenvx/dotenvx@1.75.1': + resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true - '@ecies/ciphers@0.2.6': - resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} - engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} - peerDependencies: - '@noble/ciphers': ^1.0.0 + '@dotenvx/primitives@0.8.0': + resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} '@electric-sql/pglite-tools@0.2.21': resolution: {integrity: sha512-kv8Z7UmmBVECHud63VblgQLp4A+qSklNP7H22VQqFQGrWFTodc73bubcjgmBqThFsIIiEeAEQQYwWGaK2lSDtA==} @@ -438,8 +453,8 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.0': - resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -650,8 +665,8 @@ packages: '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} - '@firebase/ai@2.13.0': - resolution: {integrity: sha512-nJJDQKqjAcbkZdZGT/5WTVLrGZ+pYhWbwKC90nNzmvtoRTtnOJaNS34fhKSHQeB9SALgD2kxuWT5I4AkytdZ/Q==} + '@firebase/ai@2.13.1': + resolution: {integrity: sha512-RhT/VViTPBSplhQSuEp62HhLvfsV+LowMh8ZUo5MMRDzG7oFtSget4Kmg5oHP50hDVyWQuQj6to9iPFEZk08Tw==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app': 0.x @@ -670,8 +685,8 @@ packages: peerDependencies: '@firebase/app': 0.x - '@firebase/app-check-compat@0.4.4': - resolution: {integrity: sha512-9iP0MvmaVagulNXmrca96U3tqNAI3j98wsC1z7rj62nnOTajlrHM//jjB9VoHqRw6/islMskp6RsKnM7vhLDqA==} + '@firebase/app-check-compat@0.4.5': + resolution: {integrity: sha512-JI17mVcZs34zO6ZeSCrw4U2iohqy+n6GIzkbmsA+TbVjmvFLkUKt3bs5M+qRBteQm/0IWzqSHYFzEQLzDTQebg==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app-compat': 0.x @@ -682,25 +697,25 @@ packages: '@firebase/app-check-types@0.5.4': resolution: {integrity: sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==} - '@firebase/app-check@0.11.4': - resolution: {integrity: sha512-G8EsbVJV9gSfoibx0dNoNOUrvr+PkL7J//+W/BST/oUassimkZeq9bjj3bKkB0pn4og5GMQ9qs7FefwP00kkgg==} + '@firebase/app-check@0.12.0': + resolution: {integrity: sha512-wMeT6HLWRAuW7Cp/5UjWBGKgjPNxWNOoNf4PRIv0weljoGMZVeqbUY7wNBWTI2/31cX1NlXx8gQruDLsUShB3Q==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app': 0.x - '@firebase/app-compat@0.5.13': - resolution: {integrity: sha512-pn3FvXwUR34kWPccDQfCKsNZcM2wD1OS+J1jeEgzM1ZNXoxR2NaF6e5DjDuRrnTwR6LN2XQQt0IqE6yKmgpCQg==} + '@firebase/app-compat@0.5.14': + resolution: {integrity: sha512-rgFmiofYsdS9ZG/Bht3OBxJtPD3zWE1cffShWubEm+4+qZeyzCbmtb1q6jOEjN9fB7uufe4rQmWOPXouR3758Q==} engines: {node: '>=20.0.0'} '@firebase/app-types@0.9.5': resolution: {integrity: sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==} - '@firebase/app@0.14.13': - resolution: {integrity: sha512-H89Jeyp31+EZk9GPu6vaeL9mEmoXgM3nASB7UPBYYS/lqAks21mO1BU1dF8NbsVTL6tgGZkGUtiGJgxtDiwHkw==} + '@firebase/app@0.15.0': + resolution: {integrity: sha512-soIskolmGgbpi0K/MfrjtdpO1220qRCbXA4Z8Qx3lM+fVwA3q40m+OM+7zBHd2nuQCrLXb33L6Oc1aBH3Y26AQ==} engines: {node: '>=20.0.0'} - '@firebase/auth-compat@0.6.7': - resolution: {integrity: sha512-XgKnOgY1Siq7gylAmLkYtHAlRxNeWEAspH+nO3gJZJnfHqoTHbr9UjJ3nHNFALYXV5CfpQlyPROyB2ztySBHBQ==} + '@firebase/auth-compat@0.6.8': + resolution: {integrity: sha512-llcBREUC4iSNKZ6rvwud7Oz9Q7aAWU6KuQLa6pdu7Q+QAQsy4JLw6yFgxwtmzabsgznHmmcsX2UjHLLzqUxi3Q==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app-compat': 0.x @@ -714,8 +729,8 @@ packages: '@firebase/app-types': 0.x '@firebase/util': 1.x - '@firebase/auth@1.13.2': - resolution: {integrity: sha512-B4w0iS7MxRg28oIh2fJFTE6cM0lYdBrW19eHpc42jqEcloUjlYyVrpPqZvqA4+v9KFEVSKEs2SfWyta7hbzkJQ==} + '@firebase/auth@1.13.3': + resolution: {integrity: sha512-bqiq4uubDN2YyQkdvSWPQeJyXAv2O76ImF41En9b6UhV5JuBVYDoHYrrrE3NzIuGkpFMKagfhMRP4Vz6t+yQSQ==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app': 0.x @@ -744,8 +759,8 @@ packages: resolution: {integrity: sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==} engines: {node: '>=20.0.0'} - '@firebase/firestore-compat@0.4.10': - resolution: {integrity: sha512-yMP3FADDjikdrQv4YmvL4EkIny6Hw+N+a2O5T40rlHiniyMpRPxgYkKiFOvMZnsqKLqBVnKqCAElC0pa/IZtdw==} + '@firebase/firestore-compat@0.4.11': + resolution: {integrity: sha512-W7o1WdwWq5aABK5Up2ncSvTQs/QGLR/fy7cVpFBNqhsXtxoMtflHf2xBIG6+aoptcuGAobddq4g2Sq27wqHaYw==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app-compat': 0.x @@ -756,8 +771,8 @@ packages: '@firebase/app-types': 0.x '@firebase/util': 1.x - '@firebase/firestore@4.15.0': - resolution: {integrity: sha512-Fj9osqYkz2Rqr7kW3/A8BRd8CyJ7yA5K8YjhihRdyJWbL+FsELVcR6DpoCplrp1IyU+xeGgTubo1UOySXpY+EA==} + '@firebase/firestore@4.16.0': + resolution: {integrity: sha512-qdHMHMvMr0nRMuZyWNR/ArWa0YlPE3C4eAbmxTASJMYXAesKPL0Y54p70moggrNPzaK7MSIIq5RDJJyntQyIYA==} engines: {node: '>=20.0.0'} peerDependencies: '@firebase/app': 0.x @@ -822,16 +837,16 @@ packages: peerDependencies: '@firebase/app': 0.x - '@firebase/remote-config-compat@0.2.25': - resolution: {integrity: sha512-FnA5S4IxFJAAFrCnYzWlO0FCaizlYdqhe42ygFMA+wE/mUP+w36iXzHyKj1OO1A+2gyMFjeRHyg8HhkJ6c5vRA==} + '@firebase/remote-config-compat@0.2.26': + resolution: {integrity: sha512-uC57Tc7GYYOCnMgLkGIVf999XlaYaPDONoa54c93YTKDctlvCZI89z0zQ2RbhGR8Zf+QuCbQHs/99vqoE84a7g==} peerDependencies: '@firebase/app-compat': 0.x '@firebase/remote-config-types@0.5.1': resolution: {integrity: sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==} - '@firebase/remote-config@0.8.4': - resolution: {integrity: sha512-lslywR5lGvHWTu4z/MPoYs3UwS3CKdeY+ELXY87087VsOpBpkD+9Orra23tA9GW683arPTDOM3CM6eKmtiOO3g==} + '@firebase/remote-config@0.8.5': + resolution: {integrity: sha512-zb+7CDGFP2wYVF1LXQoYIFdoESIQM3p0+uiW1welw8+zvDxAL50K75PKTXXtunJADUrksTVpV7mD0pn54vzJRA==} peerDependencies: '@firebase/app': 0.x @@ -887,8 +902,8 @@ packages: resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==} engines: {node: '>=14.0.0'} - '@google-cloud/paginator@6.0.1': - resolution: {integrity: sha512-HtzIe4n9b7It3MjimmFeXwQCuUsPI621e99zBTFqZjbPH7pZpRKDRptlYM0i0+nyot2XXhw0wPfVlTwwR/TyKA==} + '@google-cloud/paginator@6.0.2': + resolution: {integrity: sha512-S60tHt4oHsVvviXY5+Ti5Reg69y5WjBglwRjJqBiVdRHxniW/L+Mc3mp7yNMcxr2Gi6DkDO4aIognNWcUxf3Vg==} engines: {node: '>=18'} '@google-cloud/precise-date@5.0.1': @@ -1361,14 +1376,6 @@ packages: cpu: [x64] os: [win32] - '@noble/ciphers@1.3.0': - resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -1446,8 +1453,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.61.0': - resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} hasBin: true @@ -1496,8 +1503,8 @@ packages: '@radix-ui/primitive@1.1.4': resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} - '@radix-ui/react-accessible-icon@1.1.9': - resolution: {integrity: sha512-5W9KzJz/3DeYbGJHbZv8Q6AkxMOKUmALfc+PRg9dWwJZMk6zD37Sz8sZrF7UD6CBkiJvn7dNeRzn5G7XiCMyig==} + '@radix-ui/react-accessible-icon@1.1.10': + resolution: {integrity: sha512-TraSwZUqTcVbiDV2/RXzAXC7aeVVXchq0daPFZE7zAxYFaMzjOUggLOfQH9KFLgRizuwVKZO/crveV1eeO3/ZQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1509,8 +1516,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-accordion@1.2.13': - resolution: {integrity: sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==} + '@radix-ui/react-accordion@1.2.14': + resolution: {integrity: sha512-iE8YB9nmTBH8zd73ofBISZ8JCzgMoMkATJr7qDwa6u5F1+7mTM81V6fa71jgZ65rpjVpecDf1vSnwIFP9Ly1zw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1522,8 +1529,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-alert-dialog@1.1.16': - resolution: {integrity: sha512-vPaIgo0mxYlvcFaM9jB2Uot9TjGXMuAPEvrc6BOLeV+I5U8s1dkIoouYaa6lmSfc5SPMo5x5djOTOTvaigdGMQ==} + '@radix-ui/react-alert-dialog@1.1.17': + resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1535,8 +1542,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.9': - resolution: {integrity: sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==} + '@radix-ui/react-arrow@1.1.10': + resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1548,8 +1555,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-aspect-ratio@1.1.9': - resolution: {integrity: sha512-Xy+Dpxt/5n9rVTdPrNFmf8GwG1NlT1pzCF/z1MgOGZMLZWdWl+km+ZRWGQAPEhbkzSwYEsfYmTca8NhUtVxqnw==} + '@radix-ui/react-aspect-ratio@1.1.10': + resolution: {integrity: sha512-kbI7NrqhDeuytYrq7JjAsoXczvL8wgj2tc1MyaYWm+50bMKHCHQtVWCryslx4cCpmCTTkBcwQckE4CmmGV2haQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1561,8 +1568,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-avatar@1.1.12': - resolution: {integrity: sha512-NQCQyWC7QrDPhjMn8hUqFeU0lUrprIgm1AyMgLbzuQJibNnatdc3SSMo3/UGFu/eUkJUU1cEcKCnyhXTQzq6tA==} + '@radix-ui/react-avatar@1.2.0': + resolution: {integrity: sha512-am/CwltXtmtdtP+5FbYblYDnMa/zuKcMJP1i3/SJMDXXfj2mG+BTqLH2wucqeyyiQMursUtg/5cK+Nh2pCaSOA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1574,8 +1581,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-checkbox@1.3.4': - resolution: {integrity: sha512-m3JmIOAX5ZzZ6VPjxEU2dbTOhoHi0nT5riwcDwe8idocsWf4a5DXJLDtZ6LfJwMBx7W+A2b7kp2TgPEKtaiF6A==} + '@radix-ui/react-checkbox@1.3.5': + resolution: {integrity: sha512-pREzrmNnVwGvYaBoM64huTRK7B3lrTRuwj8A9nwhPiEtMb+yudiWh6zWAqEtP0Dzd5+iBa1Ki7V1pCxV8ExMdA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1587,8 +1594,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collapsible@1.1.13': - resolution: {integrity: sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==} + '@radix-ui/react-collapsible@1.1.14': + resolution: {integrity: sha512-9bT+FvifX1FK2Mj6UEsTdyu0cN3JaA3KdfhaBao+ONrYFy/pyOy3TU1TNw7iOk1o+0hOEq67RojlUUmoFGwxyA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1600,8 +1607,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.9': - resolution: {integrity: sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==} + '@radix-ui/react-collection@1.1.10': + resolution: {integrity: sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1622,8 +1629,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context-menu@2.3.0': - resolution: {integrity: sha512-d7CouXhAW+CGmFOqmB+IEvd3E9GcaqfgvfjCc3hfulp2pkaUCEVEGa0SN5nNWYA+IvQ6g1Pt+S5dpNn1AoY9hg==} + '@radix-ui/react-context-menu@2.3.1': + resolution: {integrity: sha512-XbrxS68W5dyiE4fAb96yvJwSVU5x66B20A99sD5Mk3xSWK/LqeOnx6TZnim1KieMjXS/CTFq8reOAjWxas2G8Q==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1644,8 +1651,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.16': - resolution: {integrity: sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==} + '@radix-ui/react-dialog@1.1.17': + resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1666,8 +1673,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.12': - resolution: {integrity: sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==} + '@radix-ui/react-dismissable-layer@1.1.13': + resolution: {integrity: sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1679,8 +1686,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.17': - resolution: {integrity: sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==} + '@radix-ui/react-dropdown-menu@2.1.18': + resolution: {integrity: sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1701,8 +1708,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.9': - resolution: {integrity: sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==} + '@radix-ui/react-focus-scope@1.1.10': + resolution: {integrity: sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1714,8 +1721,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-form@0.1.9': - resolution: {integrity: sha512-eTPyThIKDacJ3mJDvYwf/PSmsEYlOyA2Qcb+aGyWwYv+P5w57VPUkMVA2XJ9z0Du2KBY1HoHQzhPV9iYL/r4hg==} + '@radix-ui/react-form@0.1.10': + resolution: {integrity: sha512-1NfuvctVtX4sU3Mmq/IdrR8UunxiCMiVg3A5UENKhFzxUBeOyaQQ+lmaQaV7Tc8cqvBKsJL3/KGBsixK0D8WFg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1727,8 +1734,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-hover-card@1.1.16': - resolution: {integrity: sha512-hAileDBtd6CX7nlZOarOnISQ6PP4q0e16BX51ulzdZ+7IzjL0sDTVpFdmSYrIjw6zVNsfQBao5gG6AWr3qwfvA==} + '@radix-ui/react-hover-card@1.1.17': + resolution: {integrity: sha512-GjZQIEANVkuuWeztlKz6QEHe31ZX2iDfHzcTMCQVZXC0JyQrgfKWSC+LOOEw6aVV64zyjzobIzSA4AU4eKWrHA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1749,8 +1756,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-label@2.1.9': - resolution: {integrity: sha512-rDoTeMbCwRVcnmo7NGT9IlPo1yXmEI+xc1URP3oeewwZEV4mdTp1dYUhYbQdo4D1q2SjKVvv4N1gNY77QAQtjA==} + '@radix-ui/react-label@2.1.10': + resolution: {integrity: sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1762,8 +1769,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.17': - resolution: {integrity: sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==} + '@radix-ui/react-menu@2.1.18': + resolution: {integrity: sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1775,8 +1782,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menubar@1.1.17': - resolution: {integrity: sha512-AKtZ4O782yO7qwIyq73WpulYt1IHhQ0htDb6wNcxzxnSDCcSWMVBiU9ycpcA90XzQO4IVIxIErtak6Kg/Vt0rQ==} + '@radix-ui/react-menubar@1.1.18': + resolution: {integrity: sha512-hX7EGx/oFq6DPY27GQuP/2wP48GHf5LG6r06VgNJlG+znmDS8OfopZcRcGly3L4lsB9FqpmLx6JQSE9P3BUpyw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1788,8 +1795,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-navigation-menu@1.2.15': - resolution: {integrity: sha512-/fS8hKCcRt4DwCGa5QIB3juRXmfYSOk4a2AEe/BDIyy7Hm+eje2Y13oUx5zejl+wFt1owrM7E8NWlbaEl5EGpg==} + '@radix-ui/react-navigation-menu@1.2.16': + resolution: {integrity: sha512-nJ0SkrSQgudyYhMiYeHA1ayLVuduEJCFLan1RZZN7c9kqzzCFLaU9kuy81uNtqzweM9YaQPgWzxi9MwQ9jZ04g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1801,8 +1808,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-one-time-password-field@0.1.9': - resolution: {integrity: sha512-fvCzA9hm7yN5xxTPJIi4VhSmH5gv+76ILsxguBK3cm3icD5BR4vW7POQmu8Zio0yh91uuouG/Kang40IbMkaSQ==} + '@radix-ui/react-one-time-password-field@0.1.10': + resolution: {integrity: sha512-GHkcJ+WVj91At+OvUVTD4R3W0/wxw9t/sG5xFUBYXaCbtWiooZX5Md376QjJqgH4VsVyXrbVNHO2O4NYcmjfVg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1814,8 +1821,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-password-toggle-field@0.1.4': - resolution: {integrity: sha512-qoDSkObZ9faJlsjlwyBH6ia7kq9vaJ2QwWTowT3nQpzPvUTAKesmWuGJYpd91HIoJqS+5ZPXy5uFPp+HlwdaAg==} + '@radix-ui/react-password-toggle-field@0.1.5': + resolution: {integrity: sha512-fVuA82u0b/fClpbEJv8yp1nU9eSvoSEOERsU/hhf3FXGPIvkmE7oEaHEu8poowoXO39/Va7zq2E0TUcYr1dBRg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1827,8 +1834,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.16': - resolution: {integrity: sha512-8brVpAU5Uq7Bh0c8EFc4ZTf2JJTYn0o+1L+CUJB3UYIOkTjKGMgoHvduylrahdmNlr3DfH0rFq2DrbNZXgaspw==} + '@radix-ui/react-popover@1.1.17': + resolution: {integrity: sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1840,8 +1847,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.3.0': - resolution: {integrity: sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==} + '@radix-ui/react-popper@1.3.1': + resolution: {integrity: sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1853,8 +1860,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.11': - resolution: {integrity: sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==} + '@radix-ui/react-portal@1.1.12': + resolution: {integrity: sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1879,8 +1886,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.5': - resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} + '@radix-ui/react-primitive@2.1.6': + resolution: {integrity: sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1892,8 +1899,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-progress@1.1.9': - resolution: {integrity: sha512-+EOkvg1Zn1vI1+fRDfRSAiJ7BWfcDAo5ASMmbqrcLZ4s4USk2FGkoHgeb2X+CkUgo2zJMiyObwf1k44CrRWsyw==} + '@radix-ui/react-progress@1.1.10': + resolution: {integrity: sha512-JYzEg60lk79PwKM27WZyKd7PW8O4OM5jOaFfRPfOyeXmMw7tLJh5kSj+CEjVTehszuwml/AdCzPGMXBTGf4BBw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1905,8 +1912,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-radio-group@1.4.0': - resolution: {integrity: sha512-eHdV5bLx9sH+tBnbDjkIBdvQEH/c6MEtQYhTbxkaDK9qsIFFLtmJYEQFVdwhnruWotLfQmIuWEL/J+L3utE8rQ==} + '@radix-ui/react-radio-group@1.4.1': + resolution: {integrity: sha512-/SSxZdKEo2Eo29FFRKd06EfFDYp8HryKg0WYg7QLXaydPzl52YfSvCH2a3QDBRdtcuwACroJT8UVjQVgOJ7P9A==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1918,8 +1925,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.12': - resolution: {integrity: sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==} + '@radix-ui/react-roving-focus@1.1.13': + resolution: {integrity: sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1931,8 +1938,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-scroll-area@1.2.11': - resolution: {integrity: sha512-DS39ziOgea75U/TrXKU2/oKp0be2jrDHnzFLvahg/0iNAT1Zq16e4Uw0WXwyXvsK+mG3BRyMb7A3NRZMDuEXtQ==} + '@radix-ui/react-scroll-area@1.2.12': + resolution: {integrity: sha512-xuafVzQiTCLsyEjakowTdG3OgTXsmO7IdCiO77otIa+z44xoLNs9Do5eg7POFumIOCjtG6djfm6RKUKpUa/csA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1944,8 +1951,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-select@2.3.0': - resolution: {integrity: sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g==} + '@radix-ui/react-select@2.3.1': + resolution: {integrity: sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1957,8 +1964,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.9': - resolution: {integrity: sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==} + '@radix-ui/react-separator@1.1.10': + resolution: {integrity: sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1970,8 +1977,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slider@1.4.0': - resolution: {integrity: sha512-RHcPlLOThRJM51DSIC33ZnpDEBYhyEFroVWkd2P54PGGjkmAt14RboYUU9E1MFst666zFHM0tGtWvMjSOtU1pw==} + '@radix-ui/react-slider@1.4.1': + resolution: {integrity: sha512-r91WSpQucNGFKAIxT8FT0H0zyjd5tJlqObLp7LOMV4z49KoDCwjy01w3vDOU4e1wxhF9IgjYco7SB6byOW7Buw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1983,8 +1990,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.5': - resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==} + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1992,8 +1999,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-switch@1.3.0': - resolution: {integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==} + '@radix-ui/react-switch@1.3.1': + resolution: {integrity: sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2005,8 +2012,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.14': - resolution: {integrity: sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==} + '@radix-ui/react-tabs@1.1.15': + resolution: {integrity: sha512-kxc9gI6/HfcU4nfMMVS3AmQK414kbU1IE6UCJmMmxjhO3cRPXOyYnmvyKD+ODt7q56nRq9l7Wovi6uaGwKgMlg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2018,8 +2025,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toast@1.2.16': - resolution: {integrity: sha512-WUymDDiN2DpoGudRN1aW4wF5O3BNQjZZO/5nngPoNiEVqjyOzirvZZNO0R6dC1ifucSINVaSv8JX1aq47VGgiA==} + '@radix-ui/react-toast@1.2.17': + resolution: {integrity: sha512-uL4kyyWy000pPL43fGGCV5qT6ZchCWEQZOSlkYiPwPt8Hy1iW38RjeptIvz1/SZesrW6Vn58Ct3sV7tfEfiAbw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2031,8 +2038,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle-group@1.1.12': - resolution: {integrity: sha512-TEgECgJaWGAHJJZGzNNEYTNBdIXqX7LchANycpyP7DkfjmuiSN7ISt1k/ZRGVJgVJonsgP4vwaiKMn5utrcwWQ==} + '@radix-ui/react-toggle-group@1.1.13': + resolution: {integrity: sha512-Xb9PLtlvU66F36LiKba6dFswu6V2mDkgidO4fNSbQHQwmZ9ObxMIO17MN/LJ4aWJecVuSVLAHPZjyeMzJrgeiA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2044,8 +2051,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle@1.1.11': - resolution: {integrity: sha512-FikrKJemoBGZQ6uRID0HJqSPBP6D7OppdD2OhLl0ZYLlAyPXI7MezoYGmumwNkrAoRm35xXkb4C8JPfJZZzcaw==} + '@radix-ui/react-toggle@1.1.12': + resolution: {integrity: sha512-AsAVsYNZIlRBsci7BhE+QyQeKd1h6TffJYt+lF0QQkd5OpQ3klfIByPsCb4G0h/Fq6PJwh1FYNluzBFYzhk4+w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2057,8 +2064,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toolbar@1.1.12': - resolution: {integrity: sha512-4wHtJVdIgqMmEwUvxA0BYg/2JMRbt0L3+8UD8Ml/nhKkfXtiZcM8u/S15gQ5xj9YEd/0qlrm5bE805LsjQ+J8A==} + '@radix-ui/react-toolbar@1.1.13': + resolution: {integrity: sha512-Za1l4f6fzTkGgz/iynAMN8iaqiKff2wm2/QwiLmHPtDQreWEBrvSimgQFIekxMUdRPhILM7xdIXxuS/o/DGZag==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2070,8 +2077,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.9': - resolution: {integrity: sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==} + '@radix-ui/react-tooltip@1.2.10': + resolution: {integrity: sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2164,8 +2171,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.2.5': - resolution: {integrity: sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==} + '@radix-ui/react-visually-hidden@1.2.6': + resolution: {integrity: sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2198,141 +2205,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.62.0': - resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.62.0': - resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.62.0': - resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.62.0': - resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.62.0': - resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.0': - resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': - resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.62.0': - resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.62.0': - resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.62.0': - resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.62.0': - resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.62.0': - resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.62.0': - resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.62.0': - resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.62.0': - resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.62.0': - resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.62.0': - resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.62.0': - resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.62.0': - resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.62.0': - resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.62.0': - resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.62.0': - resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.0': - resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.0': - resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.0': - resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -2346,12 +2353,12 @@ packages: resolution: {integrity: sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==} engines: {node: '>= 18'} - '@sentry/browser-utils@10.58.0': - resolution: {integrity: sha512-TzXrhZq3Llj6qPSv0ZVG5N5c7C6qNN/aRKJXhq2LombJrLwiQrWdgizp7zdHA0FGlZ7F5YpyRA2JIpkhvrFqYA==} + '@sentry/browser-utils@10.62.0': + resolution: {integrity: sha512-mS9HVVuWIdye9o0xUGFmzNOBqktF4n5kugrF8NCOYYDrr5ZV8Cx7BlquHQn5UpCeViVhZtcDlEm4iOK7++Px7A==} engines: {node: '>=18'} - '@sentry/browser@10.58.0': - resolution: {integrity: sha512-Syf6h6HDwC15fk86+/0ql8ebwwJFw2wp+lvOX9GfLTJVOqrSILCrtLKNI+f4v/3w8mzImsv9ttJGGbUugLNvcA==} + '@sentry/browser@10.62.0': + resolution: {integrity: sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==} engines: {node: '>=18'} '@sentry/bundler-plugin-core@5.3.0': @@ -2410,22 +2417,26 @@ packages: engines: {node: '>= 10'} hasBin: true - '@sentry/core@10.58.0': - resolution: {integrity: sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==} + '@sentry/conventions@0.12.0': + resolution: {integrity: sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==} + engines: {node: '>=14'} + + '@sentry/core@10.62.0': + resolution: {integrity: sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==} engines: {node: '>=18'} - '@sentry/feedback@10.58.0': - resolution: {integrity: sha512-VmIlR/0O0GXITbvgjPkQqd6yM0JDEk52WXv6Rs1kTdaIDU5h3Y64VDVN4MAbYVRHqSz7F1arjRRk2FkaKC7ZOQ==} + '@sentry/feedback@10.62.0': + resolution: {integrity: sha512-d0BVjJVny6qpBgGJgWL0fbcoQHjtD3z3R8EK/KzTS3RO92JX5n3A536n5D/rh0gZFgcIwiUzBXegmyPOSQn9ng==} engines: {node: '>=18'} - '@sentry/nextjs@10.58.0': - resolution: {integrity: sha512-aiuCh9x5VcEvCrZtMyQqkx0uwd3fwp/m4Ca+3ciyY7xDD9jvYSXhh3saYWSg9neYcdAPa4Gjz2UCS/8a4H/+GQ==} + '@sentry/nextjs@10.62.0': + resolution: {integrity: sha512-JyxKNruI/yx8/YoxR/MG0IYg/zPBY9LSeUUbWPWN4jPFOD9e5/cQh4GtdY6tFWcWl2262PS7me8mbTH0J2jjzg==} engines: {node: '>=18'} peerDependencies: next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0 - '@sentry/node-core@10.58.0': - resolution: {integrity: sha512-7dTbYuoaSwSmF2GWDl7KK+sXQL8iqaZeZ2I/aFm+SvPZLckZF3OGFb2VsluWsSXQLnxtxPX9QP93viyK+VZsuA==} + '@sentry/node-core@10.62.0': + resolution: {integrity: sha512-V7rDgbxViiHU0OpcFEDp3l41IFvWTasKHfXw8SQ6yIgtZ8VpFqmz2TR5N7X85iIOmWIvK5HV0yp0eDdsly0+rA==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -2433,7 +2444,6 @@ packages: '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' '@opentelemetry/instrumentation': '>=0.57.1 <1' '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 - '@opentelemetry/semantic-conventions': ^1.39.0 peerDependenciesMeta: '@opentelemetry/api': optional: true @@ -2445,42 +2455,39 @@ packages: optional: true '@opentelemetry/sdk-trace-base': optional: true - '@opentelemetry/semantic-conventions': - optional: true - '@sentry/node@10.58.0': - resolution: {integrity: sha512-KICgacBS+I/eWzFlAembutSwFwy0WVSrGp8UMV9n1XZqqu4EBTlALRsbLNlDSv61UgH85L9L3vk91tgq6nJXAA==} + '@sentry/node@10.62.0': + resolution: {integrity: sha512-4hoU67bJY0o3irEDMZu2UIztAOsvEqFkLXA7EUKl1LXMA3Ba1Lb32OUVqlsTypiEInSDs/BtM+aAFKojZ3P3Fw==} engines: {node: '>=18'} - '@sentry/opentelemetry@10.58.0': - resolution: {integrity: sha512-qKOGVmt02wDaq7E70VekG8Z9XM641trJPoTHSeVUfGaXVcmGc46ZldTNtfWbxJq/8f/fge2pap60gn066ido2Q==} + '@sentry/opentelemetry@10.62.0': + resolution: {integrity: sha512-nFwBgtjfwgY8P5lAuQFWfAsQW1MXxuQ6kR/HtBs+A6julqwGGS2QnQ65OCWMzz6IqDEL/pRgT1405/gU+OXU3A==} engines: {node: '>=18'} peerDependencies: '@opentelemetry/api': ^1.9.0 '@opentelemetry/core': ^1.30.1 || ^2.1.0 '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 - '@opentelemetry/semantic-conventions': ^1.39.0 - '@sentry/react@10.58.0': - resolution: {integrity: sha512-3FLRtnXrue30UZALrQ9wQZeuvVmZl/pTCA+RyPlaZ5GxcYTapN9CVbm1IvbQpK4w1bt80+JxaKM4HikvII6KpA==} + '@sentry/react@10.62.0': + resolution: {integrity: sha512-PChimVpY0wzs3H/hJqyl87/ITTHwIZWTSY68QoENZyLnp7DvLcFiZYub/gFws1pzDPhtIQXVLU72fbmUjT5PSg==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/replay-canvas@10.58.0': - resolution: {integrity: sha512-ufFmaJ968DXGe6u9W/UqNVjCCTtAqT2bNtSu1jHAjIFFpWIuM80lcR33grK6SuGnFxceu5iJFaIW6JRyfbM0Sw==} + '@sentry/replay-canvas@10.62.0': + resolution: {integrity: sha512-CzPAxmpe5US/ABGA1TzpjFKOFZN5uqlzrRh/uM9/daVuzLVKIAQ0XRNxo/PPEXvlDm/PoMdI5L0qIODuIKnyyw==} engines: {node: '>=18'} - '@sentry/replay@10.58.0': - resolution: {integrity: sha512-EVasQNUenpwJksK9DI1TQ78YytpjLAhxn0UTiHqA3sU/s1fHK5XZdZJPr/9uEGedRoInIP0UIWmbOtOEX8HHDg==} + '@sentry/replay@10.62.0': + resolution: {integrity: sha512-rWp4hBhZOmdQhisxcKzAwTGiRk/LvWnNaElWe7nbRhjsM/usp2095yfjq4iJ47v9MtO7xxY6eUz++fLBycqXKg==} engines: {node: '>=18'} - '@sentry/server-utils@10.58.0': - resolution: {integrity: sha512-PywIl2jvl+tO5R4j+n72Lcf3ItanHcaMN/oL1U9ZHE8icaT2zpo2W4uOaslpQeQvqPC24HGZ3BW2etzsCFQbag==} + '@sentry/server-utils@10.62.0': + resolution: {integrity: sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA==} engines: {node: '>=18'} - '@sentry/vercel-edge@10.58.0': - resolution: {integrity: sha512-6hG/IPXdqo/MdNx/y5a3QQQTBQ/Jcux/NXLyEW+a8DvGqyKgcydB4VK7Ci7S5gcvpPkWrhGD5EWRXkYRfUNxbA==} + '@sentry/vercel-edge@10.62.0': + resolution: {integrity: sha512-p9gtIRywc6X2JOSh8WGLXCbY8969EDIF+dcejGbbQO9ekRzyREcJIxbJxfofUsynVO6dpEUi5Eayl71JhYwcPQ==} engines: {node: '>=18'} '@sentry/webpack-plugin@5.3.0': @@ -2503,99 +2510,99 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@supabase/auth-js@2.108.2': - resolution: {integrity: sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==} - engines: {node: '>=20.0.0'} + '@supabase/auth-js@2.110.0': + resolution: {integrity: sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg==} + engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.108.2': - resolution: {integrity: sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==} - engines: {node: '>=20.0.0'} + '@supabase/functions-js@2.110.0': + resolution: {integrity: sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw==} + engines: {node: '>=22.0.0'} - '@supabase/phoenix@0.4.2': - resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} + '@supabase/phoenix@0.4.4': + resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} - '@supabase/postgrest-js@2.108.2': - resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.110.0': + resolution: {integrity: sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ==} + engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.108.2': - resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==} - engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.110.0': + resolution: {integrity: sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw==} + engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.108.2': - resolution: {integrity: sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==} - engines: {node: '>=20.0.0'} + '@supabase/storage-js@2.110.0': + resolution: {integrity: sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ==} + engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.108.2': - resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==} - engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.110.0': + resolution: {integrity: sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ==} + engines: {node: '>=22.0.0'} '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@tailwindcss/node@4.3.1': - resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} - '@tailwindcss/oxide-android-arm64@4.3.1': - resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.3.1': - resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.3.1': - resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.3.1': - resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': - resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': - resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': - resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': - resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.3.1': - resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.3.1': - resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -2606,24 +2613,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': - resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': - resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.3.1': - resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} engines: {node: '>= 20'} - '@tailwindcss/postcss@4.3.1': - resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@tailwindcss/postcss@4.3.2': + resolution: {integrity: sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==} '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} @@ -2688,8 +2695,11 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.9.3': - resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} @@ -2714,63 +2724,63 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.61.0': - resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + '@typescript-eslint/eslint-plugin@8.61.1': + resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.0 + '@typescript-eslint/parser': ^8.61.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.61.0': - resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + '@typescript-eslint/parser@8.61.1': + resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.61.0': - resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + '@typescript-eslint/project-service@8.61.1': + resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.61.0': - resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + '@typescript-eslint/scope-manager@8.61.1': + resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.61.0': - resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + '@typescript-eslint/tsconfig-utils@8.61.1': + resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.61.0': - resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + '@typescript-eslint/type-utils@8.61.1': + resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.61.0': - resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + '@typescript-eslint/types@8.61.1': + resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.61.0': - resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + '@typescript-eslint/typescript-estree@8.61.1': + resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.61.0': - resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + '@typescript-eslint/utils@8.61.1': + resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.61.0': - resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + '@typescript-eslint/visitor-keys@8.61.1': + resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.12.2': @@ -2893,15 +2903,44 @@ packages: cpu: [x64] os: [win32] + '@vercel/analytics@2.0.1': + resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vercel/cli-config@0.2.0': resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} - '@vercel/cli-exec@0.1.1': - resolution: {integrity: sha512-LMRMEai3Z+BODyxGcU9+KiWrS/UElNiOLKiNRfGNt2Vu3NTEmXgFeXG9wBfocAnTe5yJCX/DY6k3k7S/LkPp/g==} + '@vercel/cli-exec@1.0.0': + resolution: {integrity: sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug==} engines: {node: '>= 18'} - '@vercel/functions@3.7.1': - resolution: {integrity: sha512-7PsCL2Vz4MKz4t+Nxu8u4mu/t66y1xv9I0njb3EYoFoFCsXchOChT7YFgXZYFQiUXFEifBpnoLma4e+ep7cKKA==} + '@vercel/functions@3.7.4': + resolution: {integrity: sha512-BvHqiuWziXaApV/eazLc84cHzEpPyNGG2UUAg82x+VN2VTCtX/DKH7SKOb3PxpRL/27zxPz8ltdO6GoKj66DaA==} engines: {node: '>= 20'} peerDependencies: '@aws-sdk/credential-provider-web-identity': '*' @@ -2912,10 +2951,36 @@ packages: ws: optional: true - '@vercel/oidc@3.6.1': - resolution: {integrity: sha512-8ipTFoiX3WBRrvXLjSrmgAiwtMDQk3EgSxe8N7v2rXBz39NBIIyoGXeVbJRoBcP8WEuVnvjvIQsggbGU7ZKrMw==} + '@vercel/oidc@3.7.1': + resolution: {integrity: sha512-RrSsVWbq3KLK5lJobyTPp3tSthNfUp+zWkXno5Wkko0oEW05rA4ngOrnVypP4+L8/Av89uPo2rWFmzL8iwnsmA==} engines: {node: '>= 20'} + '@vercel/speed-insights@2.0.0': + resolution: {integrity: sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==} + peerDependencies: + '@sveltejs/kit': ^1 || ^2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vitest/expect@4.1.9': resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} @@ -2996,9 +3061,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - abbrev@4.0.0: - resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} - engines: {node: ^20.17.0 || >=22.9.0} + abbrev@5.0.0: + resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} @@ -3106,8 +3171,8 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - anynum@1.0.0: - resolution: {integrity: sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} archiver-utils@5.0.2: resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} @@ -3117,9 +3182,6 @@ packages: resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} engines: {node: '>= 14'} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3191,6 +3253,10 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -3207,6 +3273,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3278,8 +3348,13 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.36: - resolution: {integrity: sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==} + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + baseline-browser-mapping@2.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} hasBin: true @@ -3311,8 +3386,8 @@ packages: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} boxen@5.1.2: @@ -3333,8 +3408,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -3404,8 +3479,8 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} @@ -3558,6 +3633,10 @@ packages: engines: {node: '>=22'} hasBin: true + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -3682,6 +3761,10 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3727,9 +3810,6 @@ packages: babel-plugin-macros: optional: true - deep-equal-in-any-order@2.2.0: - resolution: {integrity: sha512-lUYf3Oz/HrPcNmKe+S+QSdY5/hzKleftcFBWLwbHNZ5007RUKgN0asWlAHuQGvT9djYd9PYQFiu0TyNS+h3j/g==} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -3759,6 +3839,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} engines: {node: '>=12'} @@ -3815,6 +3899,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -3836,15 +3924,11 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - eciesjs@0.4.18: - resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} - engines: {bun: '>=1', deno: '>=2', node: '>=16'} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.372: - resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + electron-to-chromium@1.5.380: + resolution: {integrity: sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3876,8 +3960,8 @@ packages: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.24.0: - resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + enhanced-resolve@5.24.1: + resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} engines: {node: '>=10.13.0'} enquirer@2.4.1: @@ -3899,6 +3983,10 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + es-abstract@1.24.2: resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} @@ -3930,8 +4018,8 @@ packages: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + es-to-primitive@1.3.1: + resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} engines: {node: '>= 0.4'} esbuild@0.27.7: @@ -4194,8 +4282,8 @@ packages: fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - fast-xml-parser@5.8.0: - resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==} + fast-xml-parser@5.9.3: + resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} hasBin: true fastq@1.20.1: @@ -4229,10 +4317,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - filesize@6.4.0: - resolution: {integrity: sha512-mjFIpOHC4jbfcTfoh4rkWpI31mF7viw9ikj/JyLoKzqlwG/YsefKfvYlYhdYdg/9mtK2z1AzgN/0LvVQ3zdlSQ==} - engines: {node: '>= 0.4.0'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -4249,21 +4333,25 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - firebase-admin@14.0.0: - resolution: {integrity: sha512-U88/r6VWiBQ05+UlLaF1A1AN4Y3SAGQKcQWawzafEAnXVaCZ21+2KclMPdlIQAAF5pUtN+FkXCSQnJEpc6QDZA==} + firebase-admin@14.1.0: + resolution: {integrity: sha512-GdHh6vHWm9LVRt+3hINWczaA7fPwnN/l4xZdqzn+wNPYErrLI1x6u1mvAJM/5IGgYI9EqQgLt1EyBG8ok/hWCg==} engines: {node: '>=22'} - firebase-tools@15.20.0: - resolution: {integrity: sha512-QU+sIgip+i+4uL5ZxQbIHT+0JOGk4+9A/JjeQKqZdE3c+joF9mO6xz0x59d5Yb1egecZAO/9DDD0UASQuDMk+w==} + firebase-tools@15.22.3: + resolution: {integrity: sha512-AsgQnlUMa/9kvGScHvJcizW1+jhZtQXA3aBfogMYDO8vepl74D3U5cRCCAhy5h+TFtbWdmLLIy6cEBzmnXXPwg==} engines: {node: '>=20.0.0 || >=22.0.0 || >=24.0.0'} hasBin: true - firebase@12.14.0: - resolution: {integrity: sha512-aEZ/lniDR1hOCYpx/x/V8Nrrqq9pepKDNkqP/4WGZFC69gTv6F59Z4/54W/SUP4L/hFlrRNmWj35aweQq+IHow==} + firebase@12.15.0: + resolution: {integrity: sha512-p0YTLcRSTiBXMx9sGr4ZNSfLjc/RVBEw4C/TXjVMtw65+6E1Pbm47UY3F4/AqRoDobEcNX3gsbPGy7jPjxbgSQ==} flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} @@ -4283,12 +4371,12 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@2.5.5: - resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} + form-data@2.5.6: + resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==} engines: {node: '>= 0.12'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-polyfill@4.0.10: @@ -4299,8 +4387,8 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - framer-motion@12.40.0: - resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + framer-motion@12.42.1: + resolution: {integrity: sha512-Q2rHK14w8lavWk3MiF1wihcV1sCVsSYrRSaoqJ5eChG7CUNTKxDUgoMesiUSmMBjFpBRPWGrLvzymRrm0jnwFA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -4483,6 +4571,10 @@ packages: resolution: {integrity: sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==} engines: {node: '>=18'} + google-auth-library@10.9.0: + resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + engines: {node: '>=18'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -4569,8 +4661,8 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} html-encoding-sniffer@6.0.0: @@ -4644,8 +4736,8 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - import-in-the-middle@3.0.2: - resolution: {integrity: sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ==} + import-in-the-middle@3.2.0: + resolution: {integrity: sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==} engines: {node: '>=18'} import-lazy@2.1.0: @@ -4737,6 +4829,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4886,6 +4983,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-unsafe@1.0.1: + resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + is-url@1.2.4: resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} @@ -4905,6 +5005,10 @@ packages: resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} engines: {node: '>=4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} @@ -4969,10 +5073,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - js-yaml@4.2.0: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true @@ -5013,6 +5113,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} @@ -5042,8 +5145,8 @@ packages: jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - jwks-rsa@4.0.1: - resolution: {integrity: sha512-poXwUA8S4cP9P5N8tZS3xnUDJH8WmwSGfKK9gIaRPdjLHyJtd9iX/cngX9CUIe0Caof5JhK2EbN7N5lnnaf9NA==} + jwks-rsa@4.1.0: + resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==} engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0} jws@4.0.1: @@ -5074,10 +5177,6 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -5172,6 +5271,10 @@ packages: resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} engines: {node: '>=6.11.5'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -5261,8 +5364,8 @@ packages: resolution: {integrity: sha512-XTFlOBHeuXnUGuUQI0kBb4O4oQW7qCV7MRGQja1xNpxgrI/jptlly+/5126RiRold1zgyxY520qOZUZ31V0I2A==} engines: {node: '>=20.0.0'} - lucide-react@1.18.0: - resolution: {integrity: sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==} + lucide-react@1.22.0: + resolution: {integrity: sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5317,6 +5420,10 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -5360,6 +5467,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -5408,8 +5519,8 @@ packages: resolution: {integrity: sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==} engines: {node: '>= 0.8.0'} - motion-dom@12.40.0: - resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + motion-dom@12.42.1: + resolution: {integrity: sha512-dnBr0vtpJLvTVm+SIi8o7yh8jD57Bv7++nkkoeq85QW+QJqM+RIIoyvu5LyZ186dILUMHzkxv5sQveTxuOcOBw==} motion-utils@12.39.0: resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} @@ -5430,11 +5541,11 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nan@2.27.0: - resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -5516,18 +5627,18 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp@12.4.0: - resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} - engines: {node: ^20.17.0 || >=22.9.0} + node-gyp@13.0.0: + resolution: {integrity: sha512-FYYyBDWdc+kzoyPd5PqHUgM9DGs1C/Z4jxBZAOnA2GRUVXPivKRREq5q+VVPXVr9aGVqGMaMqyFHbviy/yb7Hg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true - node-releases@2.0.47: - resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} - nopt@9.0.0: - resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} - engines: {node: ^20.17.0 || >=22.9.0} + nopt@10.0.1: + resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true normalize-path@3.0.0: @@ -5582,8 +5693,8 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.2: - resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} on-finished@2.3.0: @@ -5620,6 +5731,10 @@ packages: resolution: {integrity: sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==} engines: {node: '>=8'} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + openapi3-ts@3.2.0: resolution: {integrity: sha512-/ykNWRV5Qs0Nwq7Pc0nJ78fgILvOT/60OxEmB3v7yQ8a8Bwcm43D4diaYazG/KBn6czA+52XYy931WFLMCUeSg==} @@ -5647,10 +5762,18 @@ packages: resolution: {integrity: sha512-ugZxsxmtTln604yeYd29EGrNhazN2lywetzpKhfmQjW/VJmhpDmWbiX+h0zL8V91R0UXkhb3KtPmyq9PZw3aYw==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5659,6 +5782,10 @@ packages: resolution: {integrity: sha512-aio0v+S0QVkH1O+9x4dHtD4dgCExACcL+3EtNaGqC01GBudS9ijMuUsmN8OVScyV4OOp0jqdLShZFuSlbL/AsA==} engines: {node: '>=18'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + pac-proxy-agent@7.2.0: resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} engines: {node: '>= 14'} @@ -5701,12 +5828,16 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + path-expression-matcher@1.6.1: + resolution: {integrity: sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==} engines: {node: '>=14.0.0'} path-key@3.1.1: @@ -5739,8 +5870,8 @@ packages: pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} - pg-connection-string@2.13.0: - resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} pg-gateway@0.3.0-beta.4: resolution: {integrity: sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==} @@ -5754,15 +5885,15 @@ packages: peerDependencies: pg: '>=8.0' - pg-protocol@1.14.0: - resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} pg-types@2.2.0: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.21.0: - resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -5788,13 +5919,17 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - playwright-core@1.61.0: - resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} hasBin: true - playwright@1.61.0: - resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} engines: {node: '>=18'} hasBin: true @@ -5850,9 +5985,9 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} - proc-log@6.1.0: - resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} - engines: {node: ^20.17.0 || >=22.9.0} + proc-log@7.0.0: + resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -5905,15 +6040,15 @@ packages: resolution: {integrity: sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==} engines: {node: '>=8'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - radix-ui@1.5.0: - resolution: {integrity: sha512-Nzh2HNpClgB31FBHRqt2xG8XNUfVfQRpf34hACC5PNrXTd5JdXdqOXwLs3BL+D8CNYiNQiJiT8QGr5Q4vq+00w==} + radix-ui@1.6.0: + resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -5936,6 +6071,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + raw-body@2.5.3: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} @@ -5948,9 +6087,12 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - re2@1.24.1: - resolution: {integrity: sha512-uRl9cLDKuobJQp+6lVz7E3AyVszubUJ0fqAMWout4ocUWTIFvdHgpqLwwMh/vuNGGGJGh2p2mJZJIQr9am9M/A==} - engines: {node: '>=22'} + re2@1.25.0: + resolution: {integrity: sha512-mtxKjWS+VYIt2ijgt6ohEdwzNlGPom1whyaEKJD40cBc/wqkO1vJoOyK539Qb8Xa9m4GA6hiPGDIbW/d3egSRQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + re2js@0.4.3: + resolution: {integrity: sha512-EuNmh7jurhHEE8Ge/lBo9JuMLb3qf866Xjjfyovw3wPc7+hlqDkZq4LwhrCQMEI+ARWfrKrHozEndzlpNT0WDg==} react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} @@ -6095,8 +6237,8 @@ packages: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true - rollup@4.62.0: - resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -6150,6 +6292,9 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + semifies@1.0.0: + resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} + semver-diff@3.1.1: resolution: {integrity: sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==} engines: {node: '>=8'} @@ -6158,8 +6303,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -6194,8 +6339,9 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.11.0: - resolution: {integrity: sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ==} + shadcn@4.12.0: + resolution: {integrity: sha512-o781ieQziCnXH2FKsEqxp1fnbHdbgAPO9inTSPeZ59hQfsZXuMGp3ul8oFSV5KQS4nbUK9b+DrDE6C7OvfKKQQ==} + engines: {node: '>=20.18.1'} hasBin: true sharp@0.34.5: @@ -6259,10 +6405,6 @@ packages: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sort-any@4.0.7: - resolution: {integrity: sha512-UuZVEXClHW+bVa6ZBQ4biTWmLXMP7y6/jv5arfA0rKk7ZExy+5Zm19uekIqqDx6ZuvUMu7z5Ba9FfBi6FlGXPQ==} - engines: {node: '>=12'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -6278,11 +6420,8 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - sql-formatter@15.8.1: - resolution: {integrity: sha512-nT2r90kTEYBuse9fe4r1Rp78v1mOBD35KsGc07Vo9eQSVa1TcTSnCS0zouf6BCmdzvmqBsBW+cYuBoYkHO/OWg==} + sql-formatter@15.8.2: + resolution: {integrity: sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==} hasBin: true stable-hash@0.0.5: @@ -6329,8 +6468,8 @@ packages: stream-shift@1.0.3: resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} - streamx@2.27.0: - resolution: {integrity: sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} @@ -6409,8 +6548,8 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@2.4.0: - resolution: {integrity: sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==} + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} @@ -6456,11 +6595,17 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.31.9: + resolution: {integrity: sha512-aqepyutSy94zJB552q3LGV2nPfUGZV7LoGhUUjLjs36aLzW3ghpKI7BEpEoQ/OOM+0On4RsyVp1+v6dfYQbqdw==} + engines: {node: '>=8.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.3.1: - resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} @@ -6469,8 +6614,8 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.17: + resolution: {integrity: sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==} engines: {node: '>=18'} tcp-port-used@1.0.3: @@ -6566,11 +6711,11 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.4.2: - resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + tldts-core@7.4.3: + resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} - tldts@7.4.2: - resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + tldts@7.4.3: + resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} hasBin: true tmp@0.2.7: @@ -6619,6 +6764,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -6682,8 +6828,8 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typescript-eslint@8.61.0: - resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + typescript-eslint@8.61.1: + resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -6701,16 +6847,19 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} - undici@6.26.0: - resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} - undici@7.27.2: - resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - undici@8.4.1: - resolution: {integrity: sha512-RNHlB4fxZK0IrkhBsxhlbx7s8kFWwr7rzzOqj5nvZugw3ig3RsB7KW3zVlV0eu8POl+rx5d1hmL7rRg0z1owow==} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} engines: {node: '>=22.19.0'} unicode-emoji-modifier-base@1.0.0: @@ -6786,12 +6935,8 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@11.1.1: - resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} - hasBin: true - - uuid@14.0.0: - resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true uuid@9.0.1: @@ -6815,8 +6960,8 @@ packages: peerDependencies: vite: '*' - vite@7.3.5: - resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -6988,9 +7133,9 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true - which@6.0.1: - resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} - engines: {node: ^20.17.0 || >=22.9.0} + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true why-is-node-running@2.3.0: @@ -7119,12 +7264,12 @@ packages: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} yargs@18.0.0: @@ -7184,13 +7329,37 @@ snapshots: call-me-maybe: 1.0.2 js-yaml: 4.2.0 + '@apm-js-collab/code-transformer-bundler-plugins@0.5.0': + dependencies: + '@apm-js-collab/code-transformer': 0.15.0 + es-module-lexer: 2.1.0 + magic-string: 0.30.21 + module-details-from-path: 1.0.4 + + '@apm-js-collab/code-transformer@0.15.0': + dependencies: + '@types/estree': 1.0.9 + astring: 1.9.0 + esquery: 1.7.0 + meriyah: 6.1.4 + semifies: 1.0.0 + source-map: 0.6.1 + + '@apm-js-collab/tracing-hooks@0.10.0': + dependencies: + '@apm-js-collab/code-transformer': 0.15.0 + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + '@apphosting/common@0.0.8': {} '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.8(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -7250,7 +7419,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -7410,7 +7579,7 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.1.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + '@csstools/css-color-parser@4.1.8(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.0.2 '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -7458,23 +7627,26 @@ snapshots: react: 19.2.7 tslib: 2.8.1 - '@dotenvx/dotenvx@1.71.3': + '@dotenvx/dotenvx@1.75.1': dependencies: + '@dotenvx/primitives': 0.8.0 commander: 11.1.0 + conf: 10.2.0 dotenv: 17.4.2 - eciesjs: 0.4.18 enquirer: 2.4.1 + env-paths: 2.2.1 execa: 5.1.1 fdir: 6.5.0(picomatch@4.0.4) ignore: 5.3.2 object-treeify: 1.1.33 + open: 8.4.2 picomatch: 4.0.4 + systeminformation: 5.31.9 + undici: 7.28.0 which: 4.0.0 yocto-spinner: 1.2.0 - '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': - dependencies: - '@noble/ciphers': 1.3.0 + '@dotenvx/primitives@0.8.0': {} '@electric-sql/pglite-tools@0.2.21(@electric-sql/pglite@0.3.16)': dependencies: @@ -7495,7 +7667,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.0': + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true @@ -7635,9 +7807,9 @@ snapshots: '@fastify/busboy@3.2.0': {} - '@firebase/ai@2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + '@firebase/ai@2.13.1(@firebase/app-types@0.9.5)(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/app-check-interop-types': 0.3.4 '@firebase/app-types': 0.9.5 '@firebase/component': 0.7.3 @@ -7645,11 +7817,11 @@ snapshots: '@firebase/util': 1.15.1 tslib: 2.8.1 - '@firebase/analytics-compat@0.2.28(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + '@firebase/analytics-compat@0.2.28(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0)': dependencies: - '@firebase/analytics': 0.10.22(@firebase/app@0.14.13) + '@firebase/analytics': 0.10.22(@firebase/app@0.15.0) '@firebase/analytics-types': 0.8.4 - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7658,20 +7830,20 @@ snapshots: '@firebase/analytics-types@0.8.4': {} - '@firebase/analytics@0.10.22(@firebase/app@0.14.13)': + '@firebase/analytics@0.10.22(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 - '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.15.0) '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 tslib: 2.8.1 - '@firebase/app-check-compat@0.4.4(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + '@firebase/app-check-compat@0.4.5(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-check': 0.11.4(@firebase/app@0.14.13) + '@firebase/app-check': 0.12.0(@firebase/app@0.15.0) '@firebase/app-check-types': 0.5.4 - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 @@ -7683,17 +7855,17 @@ snapshots: '@firebase/app-check-types@0.5.4': {} - '@firebase/app-check@0.11.4(@firebase/app@0.14.13)': + '@firebase/app-check@0.12.0(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 tslib: 2.8.1 - '@firebase/app-compat@0.5.13': + '@firebase/app-compat@0.5.14': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 @@ -7703,7 +7875,7 @@ snapshots: dependencies: '@firebase/logger': 0.5.1 - '@firebase/app@0.14.13': + '@firebase/app@0.15.0': dependencies: '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 @@ -7711,10 +7883,10 @@ snapshots: idb: 7.1.1 tslib: 2.8.1 - '@firebase/auth-compat@0.6.7(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + '@firebase/auth-compat@0.6.8(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 - '@firebase/auth': 1.13.2(@firebase/app@0.14.13) + '@firebase/app-compat': 0.5.14 + '@firebase/auth': 1.13.3(@firebase/app@0.15.0) '@firebase/auth-types': 0.13.1(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) '@firebase/component': 0.7.3 '@firebase/util': 1.15.1 @@ -7731,9 +7903,9 @@ snapshots: '@firebase/app-types': 0.9.5 '@firebase/util': 1.15.1 - '@firebase/auth@1.13.2(@firebase/app@0.14.13)': + '@firebase/auth@1.13.3(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 @@ -7744,9 +7916,9 @@ snapshots: '@firebase/util': 1.15.1 tslib: 2.8.1 - '@firebase/data-connect@0.7.1(@firebase/app@0.14.13)': + '@firebase/data-connect@0.7.1(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/auth-interop-types': 0.2.5 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 @@ -7777,11 +7949,11 @@ snapshots: faye-websocket: 0.11.4 tslib: 2.8.1 - '@firebase/firestore-compat@0.4.10(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + '@firebase/firestore-compat@0.4.11(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 - '@firebase/firestore': 4.15.0(@firebase/app@0.14.13) + '@firebase/firestore': 4.16.0(@firebase/app@0.15.0) '@firebase/firestore-types': 3.0.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7794,22 +7966,23 @@ snapshots: '@firebase/app-types': 0.9.5 '@firebase/util': 1.15.1 - '@firebase/firestore@4.15.0(@firebase/app@0.14.13)': + '@firebase/firestore@4.16.0(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 '@firebase/webchannel-wrapper': 1.0.6 '@grpc/grpc-js': 1.9.16 '@grpc/proto-loader': 0.7.15 + re2js: 0.4.3 tslib: 2.8.1 - '@firebase/functions-compat@0.4.5(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + '@firebase/functions-compat@0.4.5(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 - '@firebase/functions': 0.13.5(@firebase/app@0.14.13) + '@firebase/functions': 0.13.5(@firebase/app@0.15.0) '@firebase/functions-types': 0.6.4 '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7818,9 +7991,9 @@ snapshots: '@firebase/functions-types@0.6.4': {} - '@firebase/functions@0.13.5(@firebase/app@0.14.13)': + '@firebase/functions@0.13.5(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/app-check-interop-types': 0.3.4 '@firebase/auth-interop-types': 0.2.5 '@firebase/component': 0.7.3 @@ -7828,11 +8001,11 @@ snapshots: '@firebase/util': 1.15.1 tslib: 2.8.1 - '@firebase/installations-compat@0.2.22(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + '@firebase/installations-compat@0.2.22(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 - '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.15.0) '@firebase/installations-types': 0.5.4(@firebase/app-types@0.9.5) '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7844,9 +8017,9 @@ snapshots: dependencies: '@firebase/app-types': 0.9.5 - '@firebase/installations@0.6.22(@firebase/app@0.14.13)': + '@firebase/installations@0.6.22(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 '@firebase/util': 1.15.1 idb: 7.1.1 @@ -7856,11 +8029,11 @@ snapshots: dependencies: tslib: 2.8.1 - '@firebase/messaging-compat@0.2.27(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + '@firebase/messaging-compat@0.2.27(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 - '@firebase/messaging': 0.13.0(@firebase/app@0.14.13) + '@firebase/messaging': 0.13.0(@firebase/app@0.15.0) '@firebase/util': 1.15.1 tslib: 2.8.1 transitivePeerDependencies: @@ -7868,22 +8041,22 @@ snapshots: '@firebase/messaging-interop-types@0.2.5': {} - '@firebase/messaging@0.13.0(@firebase/app@0.14.13)': + '@firebase/messaging@0.13.0(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 - '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.15.0) '@firebase/messaging-interop-types': 0.2.5 '@firebase/util': 1.15.1 idb: 7.1.1 tslib: 2.8.1 - '@firebase/performance-compat@0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + '@firebase/performance-compat@0.2.25(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 - '@firebase/performance': 0.7.12(@firebase/app@0.14.13) + '@firebase/performance': 0.7.12(@firebase/app@0.15.0) '@firebase/performance-types': 0.2.4 '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7892,22 +8065,22 @@ snapshots: '@firebase/performance-types@0.2.4': {} - '@firebase/performance@0.7.12(@firebase/app@0.14.13)': + '@firebase/performance@0.7.12(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 - '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.15.0) '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 tslib: 2.8.1 web-vitals: 4.2.4 - '@firebase/remote-config-compat@0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13)': + '@firebase/remote-config-compat@0.2.26(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 '@firebase/logger': 0.5.1 - '@firebase/remote-config': 0.8.4(@firebase/app@0.14.13) + '@firebase/remote-config': 0.8.5(@firebase/app@0.15.0) '@firebase/remote-config-types': 0.5.1 '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7916,20 +8089,20 @@ snapshots: '@firebase/remote-config-types@0.5.1': {} - '@firebase/remote-config@0.8.4(@firebase/app@0.14.13)': + '@firebase/remote-config@0.8.5(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 - '@firebase/installations': 0.6.22(@firebase/app@0.14.13) + '@firebase/installations': 0.6.22(@firebase/app@0.15.0) '@firebase/logger': 0.5.1 '@firebase/util': 1.15.1 tslib: 2.8.1 - '@firebase/storage-compat@0.4.3(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)': + '@firebase/storage-compat@0.4.3(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0)': dependencies: - '@firebase/app-compat': 0.5.13 + '@firebase/app-compat': 0.5.14 '@firebase/component': 0.7.3 - '@firebase/storage': 0.14.3(@firebase/app@0.14.13) + '@firebase/storage': 0.14.3(@firebase/app@0.15.0) '@firebase/storage-types': 0.8.4(@firebase/app-types@0.9.5)(@firebase/util@1.15.1) '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7942,9 +8115,9 @@ snapshots: '@firebase/app-types': 0.9.5 '@firebase/util': 1.15.1 - '@firebase/storage@0.14.3(@firebase/app@0.14.13)': + '@firebase/storage@0.14.3(@firebase/app@0.15.0)': dependencies: - '@firebase/app': 0.14.13 + '@firebase/app': 0.15.0 '@firebase/component': 0.7.3 '@firebase/util': 1.15.1 tslib: 2.8.1 @@ -7976,7 +8149,7 @@ snapshots: dependencies: '@googleapis/sqladmin': 35.2.0 gaxios: 7.1.5 - google-auth-library: 10.7.0 + google-auth-library: 10.9.0 p-throttle: 7.0.0 transitivePeerDependencies: - supports-color @@ -7998,7 +8171,7 @@ snapshots: extend: 3.0.2 optional: true - '@google-cloud/paginator@6.0.1': + '@google-cloud/paginator@6.0.2': dependencies: extend: 3.0.2 @@ -8016,7 +8189,7 @@ snapshots: '@google-cloud/pubsub@5.3.1': dependencies: - '@google-cloud/paginator': 6.0.1 + '@google-cloud/paginator': 6.0.2 '@google-cloud/precise-date': 5.0.1 '@google-cloud/projectify': 5.0.1 '@google-cloud/promisify': 5.0.1 @@ -8025,7 +8198,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.39.0 arrify: 2.0.1 extend: 3.0.2 - google-auth-library: 10.7.0 + google-auth-library: 10.9.0 google-gax: 5.0.7 google-logging-utils: 1.1.4 heap-js: 2.7.1 @@ -8044,7 +8217,7 @@ snapshots: abort-controller: 3.0.0 async-retry: 1.3.3 duplexify: 4.1.3 - fast-xml-parser: 5.8.0 + fast-xml-parser: 5.9.3 gaxios: 6.7.1 google-auth-library: 9.15.1 html-entities: 2.6.0 @@ -8071,25 +8244,25 @@ snapshots: '@grpc/grpc-js@1.9.16': dependencies: '@grpc/proto-loader': 0.7.15 - '@types/node': 25.9.3 + '@types/node': 25.9.4 '@grpc/proto-loader@0.7.15': dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 protobufjs: 7.6.4 - yargs: 17.7.2 + yargs: 17.7.3 '@grpc/proto-loader@0.8.1': dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 protobufjs: 7.6.4 - yargs: 17.7.2 + yargs: 17.7.3 - '@hono/node-server@1.19.14(hono@4.12.25)': + '@hono/node-server@1.19.14(hono@4.12.27)': dependencies: - hono: 4.12.25 + hono: 4.12.27 '@humanfs/core@0.19.2': dependencies: @@ -8192,7 +8365,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.0 + '@emnapi/runtime': 1.11.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -8206,128 +8379,128 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@25.9.3)': + '@inquirer/checkbox@4.3.2(@types/node@26.0.1)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@26.0.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/confirm@5.1.21(@types/node@25.9.3)': + '@inquirer/confirm@5.1.21(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/core@10.3.2(@types/node@25.9.3)': + '@inquirer/core@10.3.2(@types/node@26.0.1)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@26.0.1) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/editor@4.2.23(@types/node@25.9.3)': + '@inquirer/editor@4.2.23(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/external-editor': 1.0.3(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/external-editor': 1.0.3(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/expand@4.0.23(@types/node@25.9.3)': + '@inquirer/expand@4.0.23(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/external-editor@1.0.3(@types/node@25.9.3)': + '@inquirer/external-editor@1.0.3(@types/node@26.0.1)': dependencies: - chardet: 2.1.1 + chardet: 2.2.0 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@25.9.3)': + '@inquirer/input@4.3.1(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/number@3.0.23(@types/node@25.9.3)': + '@inquirer/number@3.0.23(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/password@4.0.23(@types/node@25.9.3)': + '@inquirer/password@4.0.23(@types/node@26.0.1)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/prompts@7.10.1(@types/node@25.9.3)': + '@inquirer/prompts@7.10.1(@types/node@26.0.1)': dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@25.9.3) - '@inquirer/confirm': 5.1.21(@types/node@25.9.3) - '@inquirer/editor': 4.2.23(@types/node@25.9.3) - '@inquirer/expand': 4.0.23(@types/node@25.9.3) - '@inquirer/input': 4.3.1(@types/node@25.9.3) - '@inquirer/number': 3.0.23(@types/node@25.9.3) - '@inquirer/password': 4.0.23(@types/node@25.9.3) - '@inquirer/rawlist': 4.1.11(@types/node@25.9.3) - '@inquirer/search': 3.2.2(@types/node@25.9.3) - '@inquirer/select': 4.4.2(@types/node@25.9.3) + '@inquirer/checkbox': 4.3.2(@types/node@26.0.1) + '@inquirer/confirm': 5.1.21(@types/node@26.0.1) + '@inquirer/editor': 4.2.23(@types/node@26.0.1) + '@inquirer/expand': 4.0.23(@types/node@26.0.1) + '@inquirer/input': 4.3.1(@types/node@26.0.1) + '@inquirer/number': 3.0.23(@types/node@26.0.1) + '@inquirer/password': 4.0.23(@types/node@26.0.1) + '@inquirer/rawlist': 4.1.11(@types/node@26.0.1) + '@inquirer/search': 3.2.2(@types/node@26.0.1) + '@inquirer/select': 4.4.2(@types/node@26.0.1) optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/rawlist@4.1.11(@types/node@25.9.3)': + '@inquirer/rawlist@4.1.11(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) + '@inquirer/type': 3.0.10(@types/node@26.0.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/search@3.2.2(@types/node@25.9.3)': + '@inquirer/search@3.2.2(@types/node@26.0.1)': dependencies: - '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@26.0.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/select@4.4.2(@types/node@25.9.3)': + '@inquirer/select@4.4.2(@types/node@26.0.1)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@25.9.3) + '@inquirer/core': 10.3.2(@types/node@26.0.1) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@25.9.3) + '@inquirer/type': 3.0.10(@types/node@26.0.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 - '@inquirer/type@3.0.10(@types/node@25.9.3)': + '@inquirer/type@3.0.10(@types/node@26.0.1)': optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.0.1 '@isaacs/cliui@8.0.2': dependencies: @@ -8372,7 +8545,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.25) + '@hono/node-server': 1.19.14(hono@4.12.27) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -8382,7 +8555,7 @@ snapshots: eventsource-parser: 3.1.0 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.25 + hono: 4.12.27 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -8392,6 +8565,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.27) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.27 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8429,13 +8624,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.9': optional: true - '@noble/ciphers@1.3.0': {} - - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 - - '@noble/hashes@1.8.0': {} + '@noble/hashes@1.8.0': + optional: true '@nodable/entities@2.2.0': optional: true @@ -8474,7 +8664,7 @@ snapshots: dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.214.0 - import-in-the-middle: 3.0.2 + import-in-the-middle: 3.2.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color @@ -8501,9 +8691,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.61.0': + '@playwright/test@1.61.1': dependencies: - playwright: 1.61.0 + playwright: 1.61.1 '@pnpm/config.env-replace@1.1.0': {} @@ -8541,25 +8731,25 @@ snapshots: '@radix-ui/primitive@1.1.4': {} - '@radix-ui/react-accessible-icon@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accessible-icon@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-accordion@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-accordion@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8567,42 +8757,41 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-alert-dialog@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-arrow@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-aspect-ratio@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-aspect-ratio@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-avatar@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-avatar@1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8612,13 +8801,13 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-checkbox@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-checkbox@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8628,14 +8817,14 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collapsible@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collapsible@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -8644,12 +8833,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collection@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collection@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -8662,12 +8851,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-context-menu@2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-context-menu@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8681,19 +8870,19 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dialog@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 @@ -8709,11 +8898,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -8722,14 +8911,14 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dropdown-menu@2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dropdown-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8743,10 +8932,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-focus-scope@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8754,30 +8943,30 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-form@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-form@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-label': 2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-hover-card@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-hover-card@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8792,32 +8981,32 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-label@2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-label@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-menu@2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-menu@2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 @@ -8827,17 +9016,17 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-menubar@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-menubar@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8845,38 +9034,38 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-navigation-menu@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-navigation-menu@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-one-time-password-field@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-one-time-password-field@0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) @@ -8887,13 +9076,13 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-password-toggle-field@0.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-password-toggle-field@0.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) @@ -8903,20 +9092,20 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popover@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popover@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 @@ -8926,13 +9115,13 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popper@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popper@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8944,9 +9133,9 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-portal@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -8963,34 +9152,34 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-progress@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-progress@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-radio-group@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-radio-group@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -9000,15 +9189,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-roving-focus@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-roving-focus@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -9017,7 +9206,7 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-scroll-area@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-scroll-area@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 '@radix-ui/primitive': 1.1.4 @@ -9025,7 +9214,7 @@ snapshots: '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 @@ -9034,28 +9223,28 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-select@2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-select@2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9064,24 +9253,24 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-separator@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-separator@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slider@1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-slider@1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/number': 1.1.2 '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -9092,19 +9281,19 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slot@1.2.5(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-switch@1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-switch@1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -9114,15 +9303,15 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tabs@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tabs@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9130,34 +9319,34 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toast@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toast@1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle-group@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle-group@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9165,10 +9354,10 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -9176,35 +9365,35 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toolbar@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toolbar@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tooltip@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tooltip@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.4 '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9271,9 +9460,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-visually-hidden@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -9282,9 +9471,9 @@ snapshots: '@radix-ui/rect@1.1.2': {} - '@rollup/plugin-commonjs@28.0.1(rollup@4.62.0)': + '@rollup/plugin-commonjs@28.0.1(rollup@4.62.2)': dependencies: - '@rollup/pluginutils': 5.4.0(rollup@4.62.0) + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.4) @@ -9292,89 +9481,89 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.4 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.2 - '@rollup/pluginutils@5.4.0(rollup@4.62.0)': + '@rollup/pluginutils@5.4.0(rollup@4.62.2)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.2 - '@rollup/rollup-android-arm-eabi@4.62.0': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.62.0': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.62.0': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.62.0': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.62.0': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.62.0': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.0': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.0': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.62.0': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.0': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.62.0': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.0': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.0': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.0': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.0': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.0': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.0': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.62.0': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-openbsd-x64@4.62.0': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-openharmony-arm64@4.62.0': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.0': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.0': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.62.0': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.62.0': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@rtsao/scc@1.1.0': {} @@ -9383,17 +9572,17 @@ snapshots: '@sentry/babel-plugin-component-annotate@5.3.0': {} - '@sentry/browser-utils@10.58.0': + '@sentry/browser-utils@10.62.0': dependencies: - '@sentry/core': 10.58.0 + '@sentry/core': 10.62.0 - '@sentry/browser@10.58.0': + '@sentry/browser@10.62.0': dependencies: - '@sentry/browser-utils': 10.58.0 - '@sentry/core': 10.58.0 - '@sentry/feedback': 10.58.0 - '@sentry/replay': 10.58.0 - '@sentry/replay-canvas': 10.58.0 + '@sentry/browser-utils': 10.62.0 + '@sentry/core': 10.62.0 + '@sentry/feedback': 10.62.0 + '@sentry/replay': 10.62.0 + '@sentry/replay-canvas': 10.62.0 '@sentry/bundler-plugin-core@5.3.0': dependencies: @@ -9452,27 +9641,29 @@ snapshots: - encoding - supports-color - '@sentry/core@10.58.0': {} + '@sentry/conventions@0.12.0': {} - '@sentry/feedback@10.58.0': + '@sentry/core@10.62.0': {} + + '@sentry/feedback@10.62.0': dependencies: - '@sentry/core': 10.58.0 + '@sentry/core': 10.62.0 - '@sentry/nextjs@10.58.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.107.2(lightningcss@1.32.0))': + '@sentry/nextjs@10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(webpack@5.107.2(lightningcss@1.32.0))': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/semantic-conventions': 1.41.1 - '@rollup/plugin-commonjs': 28.0.1(rollup@4.62.0) - '@sentry/browser-utils': 10.58.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.62.2) + '@sentry/browser-utils': 10.62.0 '@sentry/bundler-plugin-core': 5.3.0 - '@sentry/core': 10.58.0 - '@sentry/node': 10.58.0 - '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) - '@sentry/react': 10.58.0(react@19.2.7) - '@sentry/vercel-edge': 10.58.0 + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.62.0 + '@sentry/node': 10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/react': 10.62.0(react@19.2.7) + '@sentry/vercel-edge': 10.62.0 '@sentry/webpack-plugin': 5.3.0(webpack@5.107.2(lightningcss@1.32.0)) - next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - rollup: 4.62.0 + next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rollup: 4.62.2 stacktrace-parser: 0.1.11 transitivePeerDependencies: - '@opentelemetry/core' @@ -9483,67 +9674,73 @@ snapshots: - supports-color - webpack - '@sentry/node-core@10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)': + '@sentry/node-core@10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': dependencies: - '@sentry/core': 10.58.0 - '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) - import-in-the-middle: 3.0.2 + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.62.0 + '@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + import-in-the-middle: 3.2.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 - '@sentry/node@10.58.0': + '@sentry/node@10.62.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.41.1 - '@sentry/core': 10.58.0 - '@sentry/node-core': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) - '@sentry/opentelemetry': 10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1) - '@sentry/server-utils': 10.58.0 - import-in-the-middle: 3.0.2 + '@sentry/core': 10.62.0 + '@sentry/node-core': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/opentelemetry': 10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)) + '@sentry/server-utils': 10.62.0 + import-in-the-middle: 3.2.0 transitivePeerDependencies: + - '@opentelemetry/core' - '@opentelemetry/exporter-trace-otlp-http' - supports-color - '@sentry/opentelemetry@10.58.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.41.1)': + '@sentry/opentelemetry@10.62.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) - '@opentelemetry/semantic-conventions': 1.41.1 - '@sentry/core': 10.58.0 + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.62.0 - '@sentry/react@10.58.0(react@19.2.7)': + '@sentry/react@10.62.0(react@19.2.7)': dependencies: - '@sentry/browser': 10.58.0 - '@sentry/core': 10.58.0 + '@sentry/browser': 10.62.0 + '@sentry/core': 10.62.0 react: 19.2.7 - '@sentry/replay-canvas@10.58.0': + '@sentry/replay-canvas@10.62.0': dependencies: - '@sentry/core': 10.58.0 - '@sentry/replay': 10.58.0 + '@sentry/core': 10.62.0 + '@sentry/replay': 10.62.0 - '@sentry/replay@10.58.0': + '@sentry/replay@10.62.0': dependencies: - '@sentry/browser-utils': 10.58.0 - '@sentry/core': 10.58.0 + '@sentry/browser-utils': 10.62.0 + '@sentry/core': 10.62.0 - '@sentry/server-utils@10.58.0': + '@sentry/server-utils@10.62.0': dependencies: - '@sentry/core': 10.58.0 + '@apm-js-collab/code-transformer': 0.15.0 + '@apm-js-collab/code-transformer-bundler-plugins': 0.5.0 + '@apm-js-collab/tracing-hooks': 0.10.0 + '@sentry/conventions': 0.12.0 + '@sentry/core': 10.62.0 + magic-string: 0.30.21 + transitivePeerDependencies: + - supports-color - '@sentry/vercel-edge@10.58.0': + '@sentry/vercel-edge@10.62.0': dependencies: '@opentelemetry/api': 1.9.1 - '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) - '@sentry/core': 10.58.0 + '@sentry/core': 10.62.0 '@sentry/webpack-plugin@5.3.0(webpack@5.107.2(lightningcss@1.32.0))': dependencies: @@ -9564,43 +9761,43 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@supabase/auth-js@2.108.2': + '@supabase/auth-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.108.2': + '@supabase/functions-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/phoenix@0.4.2': {} + '@supabase/phoenix@0.4.4': {} - '@supabase/postgrest-js@2.108.2': + '@supabase/postgrest-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.108.2': + '@supabase/realtime-js@2.110.0': dependencies: - '@supabase/phoenix': 0.4.2 + '@supabase/phoenix': 0.4.4 tslib: 2.8.1 - '@supabase/storage-js@2.108.2': + '@supabase/storage-js@2.110.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.108.2': + '@supabase/supabase-js@2.110.0': dependencies: - '@supabase/auth-js': 2.108.2 - '@supabase/functions-js': 2.108.2 - '@supabase/postgrest-js': 2.108.2 - '@supabase/realtime-js': 2.108.2 - '@supabase/storage-js': 2.108.2 + '@supabase/auth-js': 2.110.0 + '@supabase/functions-js': 2.110.0 + '@supabase/postgrest-js': 2.110.0 + '@supabase/realtime-js': 2.110.0 + '@supabase/storage-js': 2.110.0 '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.3.1': + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 enhanced-resolve: 5.21.6 @@ -9608,66 +9805,66 @@ snapshots: lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 - '@tailwindcss/oxide-android-arm64@4.3.1': + '@tailwindcss/oxide-android-arm64@4.3.2': optional: true - '@tailwindcss/oxide-darwin-arm64@4.3.1': + '@tailwindcss/oxide-darwin-arm64@4.3.2': optional: true - '@tailwindcss/oxide-darwin-x64@4.3.1': + '@tailwindcss/oxide-darwin-x64@4.3.2': optional: true - '@tailwindcss/oxide-freebsd-x64@4.3.1': + '@tailwindcss/oxide-freebsd-x64@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.3.1': + '@tailwindcss/oxide-linux-x64-musl@4.3.2': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.3.1': + '@tailwindcss/oxide-wasm32-wasi@4.3.2': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': optional: true - '@tailwindcss/oxide@4.3.1': + '@tailwindcss/oxide@4.3.2': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-arm64': 4.3.1 - '@tailwindcss/oxide-darwin-x64': 4.3.1 - '@tailwindcss/oxide-freebsd-x64': 4.3.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 - '@tailwindcss/oxide-linux-x64-musl': 4.3.1 - '@tailwindcss/oxide-wasm32-wasi': 4.3.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/postcss@4.3.1': + '@tailwindcss/postcss@4.3.2': dependencies: '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.3.1 - '@tailwindcss/oxide': 4.3.1 + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 postcss: 8.5.15 - tailwindcss: 4.3.1 + tailwindcss: 4.3.2 '@testing-library/dom@10.4.1': dependencies: @@ -9736,18 +9933,22 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.9.3 + '@types/node': 26.0.1 '@types/ms@2.1.0': {} - '@types/node@25.9.3': + '@types/node@25.9.4': dependencies: undici-types: 7.24.6 + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + '@types/pg@8.20.0': dependencies: - '@types/node': 25.9.3 - pg-protocol: 1.14.0 + '@types/node': 25.9.4 + pg-protocol: 1.15.0 pg-types: 2.2.0 '@types/react-dom@19.2.3(@types/react@19.2.17)': @@ -9761,9 +9962,9 @@ snapshots: '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 25.9.3 + '@types/node': 26.0.1 '@types/tough-cookie': 4.0.5 - form-data: 2.5.5 + form-data: 2.5.6 optional: true '@types/tough-cookie@4.0.5': @@ -9773,14 +9974,14 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/type-utils': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 eslint: 9.39.4(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 @@ -9789,41 +9990,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.1 debug: 4.4.3 eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.61.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.61.0': + '@typescript-eslint/scope-manager@8.61.1': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 - '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.61.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.7.0) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -9831,37 +10032,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/types@8.61.1': {} - '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.61.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/project-service': 8.61.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@5.9.3) + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/visitor-keys': 8.61.1 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.4 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.1 + '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.61.0': + '@typescript-eslint/visitor-keys@8.61.1': dependencies: - '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/types': 8.61.1 eslint-visitor-keys: 5.0.1 '@unrs/resolver-binding-android-arm-eabi@1.12.2': @@ -9934,27 +10135,37 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vercel/analytics@2.0.1(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + optionalDependencies: + next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + '@vercel/cli-config@0.2.0': dependencies: xdg-app-paths: 5.5.1 zod: 4.1.11 - '@vercel/cli-exec@0.1.1': + '@vercel/cli-exec@1.0.0': dependencies: execa: 5.1.1 - '@vercel/functions@3.7.1(ws@8.21.0)': + '@vercel/functions@3.7.4(ws@8.21.0)': dependencies: - '@vercel/oidc': 3.6.1 + '@vercel/oidc': 3.7.1 optionalDependencies: ws: 8.21.0 - '@vercel/oidc@3.6.1': + '@vercel/oidc@3.7.1': dependencies: '@vercel/cli-config': 0.2.0 - '@vercel/cli-exec': 0.1.1 + '@vercel/cli-exec': 1.0.0 jose: 5.10.0 + '@vercel/speed-insights@2.0.0(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + optionalDependencies: + next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -9964,13 +10175,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -10076,7 +10287,7 @@ snapshots: '@xtuc/long@4.2.2': {} - abbrev@4.0.0: + abbrev@5.0.0: optional: true abort-controller@3.0.0: @@ -10171,7 +10382,7 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - anynum@1.0.0: + anynum@1.0.1: optional: true archiver-utils@5.0.2: @@ -10198,10 +10409,6 @@ snapshots: - bare-buffer - react-native-b4a - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -10299,6 +10506,8 @@ snapshots: dependencies: tslib: 2.8.1 + astring@1.9.0: {} + async-function@1.0.0: {} async-lock@1.4.1: {} @@ -10312,6 +10521,8 @@ snapshots: asynckit@0.4.0: {} + atomically@1.7.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -10348,7 +10559,7 @@ snapshots: bare-stream@2.13.3(bare-events@2.9.1): dependencies: b4a: 1.8.1 - streamx: 2.27.0 + streamx: 2.28.0 teex: 1.0.1 optionalDependencies: bare-events: 2.9.1 @@ -10361,7 +10572,9 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.36: {} + baseline-browser-mapping@2.10.38: {} + + baseline-browser-mapping@2.10.40: {} basic-auth-connect@1.1.0: dependencies: @@ -10397,22 +10610,22 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.4.24 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.15.3 raw-body: 2.5.3 type-is: 1.6.18 unpipe: 1.0.0 transitivePeerDependencies: - supports-color - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.15.3 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -10446,13 +10659,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.2: + browserslist@4.28.4: dependencies: - baseline-browser-mapping: 2.10.36 + baseline-browser-mapping: 2.10.40 caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.372 - node-releases: 2.0.47 - update-browserslist-db: 1.2.3(browserslist@4.28.2) + electron-to-chromium: 1.5.380 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) buffer-crc32@1.0.0: {} @@ -10512,7 +10725,7 @@ snapshots: char-regex@1.0.2: {} - chardet@2.1.1: {} + chardet@2.2.0: {} chokidar@3.6.0: dependencies: @@ -10559,7 +10772,7 @@ snapshots: mz: 2.7.0 parse5: 5.1.1 parse5-htmlparser2-tree-adapter: 6.0.1 - yargs: 16.2.0 + yargs: 16.2.2 cli-spinners@2.9.2: {} @@ -10671,6 +10884,19 @@ snapshots: tree-kill: 1.2.2 yargs: 18.0.0 + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.5 + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -10791,6 +11017,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10811,10 +11041,6 @@ snapshots: dedent@1.7.2: {} - deep-equal-in-any-order@2.2.0: - dependencies: - sort-any: 4.0.7 - deep-extend@0.6.0: {} deep-freeze@0.0.1: {} @@ -10840,6 +11066,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} define-properties@1.2.1: @@ -10882,6 +11110,10 @@ snapshots: dependencies: is-obj: 2.0.0 + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + dotenv@16.6.1: {} dotenv@17.4.2: {} @@ -10905,16 +11137,9 @@ snapshots: dependencies: safe-buffer: 5.2.1 - eciesjs@0.4.18: - dependencies: - '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - ee-first@1.1.1: {} - electron-to-chromium@1.5.372: {} + electron-to-chromium@1.5.380: {} emoji-regex@10.6.0: {} @@ -10939,7 +11164,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 - enhanced-resolve@5.24.0: + enhanced-resolve@5.24.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -10959,6 +11184,13 @@ snapshots: dependencies: is-arrayish: 0.2.1 + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 @@ -10973,7 +11205,7 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 + es-to-primitive: 1.3.1 function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 @@ -11056,8 +11288,10 @@ snapshots: dependencies: hasown: 2.0.4 - es-to-primitive@1.3.0: + es-to-primitive@1.3.1: dependencies: + es-abstract-get: 1.0.0 + es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 is-symbol: 1.1.1 @@ -11107,18 +11341,18 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-next@16.2.9(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + eslint-config-next@16.2.9(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.2.9 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.7.0)) globals: 16.4.0 - typescript-eslint: 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + typescript-eslint: 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -11135,7 +11369,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -11146,22 +11380,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11172,7 +11406,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11184,7 +11418,7 @@ snapshots: string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -11395,9 +11629,9 @@ snapshots: lodash: 4.18.1 openapi3-ts: 3.2.0 promise-breaker: 6.0.0 - qs: 6.15.2 + qs: 6.15.3 raw-body: 2.5.3 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -11434,7 +11668,7 @@ snapshots: parseurl: 1.3.3 path-to-regexp: 0.1.13 proxy-addr: 2.0.7 - qs: 6.15.2 + qs: 6.15.3 range-parser: 1.2.1 safe-buffer: 5.2.1 send: 0.19.2 @@ -11450,7 +11684,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -11469,8 +11703,8 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 - range-parser: 1.2.1 + qs: 6.15.3 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 @@ -11512,16 +11746,17 @@ snapshots: fast-xml-builder@1.2.0: dependencies: - path-expression-matcher: 1.5.0 + path-expression-matcher: 1.6.1 xml-naming: 0.1.0 optional: true - fast-xml-parser@5.8.0: + fast-xml-parser@5.9.3: dependencies: '@nodable/entities': 2.2.0 fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.4.0 + is-unsafe: 1.0.1 + path-expression-matcher: 1.6.1 + strnum: 2.4.1 xml-naming: 0.1.0 optional: true @@ -11552,8 +11787,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - filesize@6.4.0: {} - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -11593,12 +11826,16 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 path-exists: 4.0.0 - firebase-admin@14.0.0: + firebase-admin@14.1.0: dependencies: '@fastify/busboy': 3.2.0 '@firebase/database-compat': 2.1.4 @@ -11607,7 +11844,7 @@ snapshots: fast-deep-equal: 3.1.3 google-auth-library: 10.7.0 jsonwebtoken: 9.0.3 - jwks-rsa: 4.0.1 + jwks-rsa: 4.1.0 optionalDependencies: '@google-cloud/firestore': 8.6.0 '@google-cloud/storage': 7.21.0 @@ -11615,15 +11852,15 @@ snapshots: - encoding - supports-color - firebase-tools@15.20.0(@types/node@25.9.3): + firebase-tools@15.22.3(@types/node@26.0.1): dependencies: '@apphosting/common': 0.0.8 '@electric-sql/pglite': 0.3.16 '@electric-sql/pglite-tools': 0.2.21(@electric-sql/pglite@0.3.16) '@google-cloud/cloud-sql-connector': 1.11.1 '@google-cloud/pubsub': 5.3.1 - '@inquirer/prompts': 7.10.1(@types/node@25.9.3) - '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + '@inquirer/prompts': 7.10.1(@types/node@26.0.1) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) abort-controller: 3.0.0 ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) @@ -11640,21 +11877,18 @@ snapshots: cross-env: 7.0.3 cross-spawn: 7.0.6 csv-parse: 5.6.0 - deep-equal-in-any-order: 2.2.0 exegesis: 4.3.0 exegesis-express: 4.0.0 express: 4.22.2 - filesize: 6.4.0 - form-data: 4.0.5 + form-data: 4.0.6 fs-extra: 10.1.0 fuzzy: 0.1.3 gaxios: 6.7.1 glob: 10.4.5 google-auth-library: 9.15.1 ignore: 7.0.5 - js-yaml: 3.14.2 + js-yaml: 4.2.0 jsonwebtoken: 9.0.3 - leven: 3.1.0 libsodium-wrappers: 0.7.16 lodash: 4.18.1 lsofi: 2.0.0 @@ -11666,32 +11900,29 @@ snapshots: node-fetch: 2.7.0 open: 6.4.0 ora: 5.4.1 - p-limit: 3.1.0 - pg: 8.21.0 + pg: 8.22.0 pg-gateway: 0.3.0-beta.4 pglite-2: '@electric-sql/pglite@0.2.17' portfinder: 1.0.38 progress: 2.0.3 proxy-agent: 6.5.0 retry: 0.13.1 - semver: 7.8.4 - sql-formatter: 15.8.1 + semver: 7.8.5 + sql-formatter: 15.8.2 stream-chain: 2.2.5 stream-json: 1.9.1 superstatic: 10.0.0 - tar: 7.5.16 + tar: 7.5.17 tcp-port-used: 1.0.3 tmp: 0.2.7 triple-beam: 1.4.1 universal-analytics: 0.5.4 update-notifier-cjs: 5.1.7 - uuid: 11.1.1 winston: 3.19.0 winston-transport: 4.9.0 ws: 7.5.11 yaml: 2.9.0 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) + zod: 4.4.3 transitivePeerDependencies: - '@cfworker/json-schema' - '@types/node' @@ -11704,35 +11935,35 @@ snapshots: - supports-color - utf-8-validate - firebase@12.14.0: + firebase@12.15.0: dependencies: - '@firebase/ai': 2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) - '@firebase/analytics': 0.10.22(@firebase/app@0.14.13) - '@firebase/analytics-compat': 0.2.28(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) - '@firebase/app': 0.14.13 - '@firebase/app-check': 0.11.4(@firebase/app@0.14.13) - '@firebase/app-check-compat': 0.4.4(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) - '@firebase/app-compat': 0.5.13 + '@firebase/ai': 2.13.1(@firebase/app-types@0.9.5)(@firebase/app@0.15.0) + '@firebase/analytics': 0.10.22(@firebase/app@0.15.0) + '@firebase/analytics-compat': 0.2.28(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0) + '@firebase/app': 0.15.0 + '@firebase/app-check': 0.12.0(@firebase/app@0.15.0) + '@firebase/app-check-compat': 0.4.5(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0) + '@firebase/app-compat': 0.5.14 '@firebase/app-types': 0.9.5 - '@firebase/auth': 1.13.2(@firebase/app@0.14.13) - '@firebase/auth-compat': 0.6.7(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) - '@firebase/data-connect': 0.7.1(@firebase/app@0.14.13) + '@firebase/auth': 1.13.3(@firebase/app@0.15.0) + '@firebase/auth-compat': 0.6.8(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0) + '@firebase/data-connect': 0.7.1(@firebase/app@0.15.0) '@firebase/database': 1.1.3 '@firebase/database-compat': 2.1.4 - '@firebase/firestore': 4.15.0(@firebase/app@0.14.13) - '@firebase/firestore-compat': 0.4.10(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) - '@firebase/functions': 0.13.5(@firebase/app@0.14.13) - '@firebase/functions-compat': 0.4.5(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) - '@firebase/installations': 0.6.22(@firebase/app@0.14.13) - '@firebase/installations-compat': 0.2.22(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) - '@firebase/messaging': 0.13.0(@firebase/app@0.14.13) - '@firebase/messaging-compat': 0.2.27(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) - '@firebase/performance': 0.7.12(@firebase/app@0.14.13) - '@firebase/performance-compat': 0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) - '@firebase/remote-config': 0.8.4(@firebase/app@0.14.13) - '@firebase/remote-config-compat': 0.2.25(@firebase/app-compat@0.5.13)(@firebase/app@0.14.13) - '@firebase/storage': 0.14.3(@firebase/app@0.14.13) - '@firebase/storage-compat': 0.4.3(@firebase/app-compat@0.5.13)(@firebase/app-types@0.9.5)(@firebase/app@0.14.13) + '@firebase/firestore': 4.16.0(@firebase/app@0.15.0) + '@firebase/firestore-compat': 0.4.11(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0) + '@firebase/functions': 0.13.5(@firebase/app@0.15.0) + '@firebase/functions-compat': 0.4.5(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0) + '@firebase/installations': 0.6.22(@firebase/app@0.15.0) + '@firebase/installations-compat': 0.2.22(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0) + '@firebase/messaging': 0.13.0(@firebase/app@0.15.0) + '@firebase/messaging-compat': 0.2.27(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0) + '@firebase/performance': 0.7.12(@firebase/app@0.15.0) + '@firebase/performance-compat': 0.2.25(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0) + '@firebase/remote-config': 0.8.5(@firebase/app@0.15.0) + '@firebase/remote-config-compat': 0.2.26(@firebase/app-compat@0.5.14)(@firebase/app@0.15.0) + '@firebase/storage': 0.14.3(@firebase/app@0.15.0) + '@firebase/storage-compat': 0.4.3(@firebase/app-compat@0.5.14)(@firebase/app-types@0.9.5)(@firebase/app@0.15.0) '@firebase/util': 1.15.1 transitivePeerDependencies: - '@react-native-async-storage/async-storage' @@ -11755,7 +11986,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@2.5.5: + form-data@2.5.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -11765,7 +11996,7 @@ snapshots: safe-buffer: 5.2.1 optional: true - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -11779,9 +12010,9 @@ snapshots: forwarded@0.2.0: {} - framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + framer-motion@12.42.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - motion-dom: 12.40.0 + motion-dom: 12.42.1 motion-utils: 12.39.0 tslib: 2.8.1 optionalDependencies: @@ -11989,7 +12220,7 @@ snapshots: ecdsa-sig-formatter: 1.0.11 gaxios: 7.1.5 gcp-metadata: 8.1.3 - google-logging-utils: 1.1.4 + google-logging-utils: 1.1.3 gtoken: 8.0.0 jws: 4.0.1 transitivePeerDependencies: @@ -12006,6 +12237,17 @@ snapshots: transitivePeerDependencies: - supports-color + google-auth-library@10.9.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.5 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -12046,7 +12288,7 @@ snapshots: gaxios: 7.1.3 google-auth-library: 10.5.0 google-logging-utils: 1.1.3 - qs: 6.15.2 + qs: 6.15.3 url-template: 2.0.8 transitivePeerDependencies: - supports-color @@ -12106,7 +12348,7 @@ snapshots: highlight.js@10.7.3: {} - hono@4.12.25: {} + hono@4.12.27: {} html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: @@ -12186,7 +12428,7 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - import-in-the-middle@3.0.2: + import-in-the-middle@3.2.0: dependencies: acorn: 8.17.0 acorn-import-attributes: 1.9.5(acorn@8.17.0) @@ -12251,7 +12493,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 is-callable@1.2.7: {} @@ -12274,6 +12516,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-docker@3.0.0: {} is-document.all@1.0.0: @@ -12388,6 +12632,9 @@ snapshots: is-unicode-supported@2.1.0: {} + is-unsafe@1.0.1: + optional: true + is-url@1.2.4: {} is-weakmap@2.0.2: {} @@ -12403,6 +12650,10 @@ snapshots: is-wsl@1.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -12452,7 +12703,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -12472,11 +12723,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.2.0: dependencies: argparse: 2.0.1 @@ -12498,7 +12744,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.27.2 + undici: 7.28.0 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -12527,6 +12773,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@7.0.3: {} + json-schema-typed@8.0.2: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -12554,7 +12802,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.4 + semver: 7.8.5 jsx-ast-utils@3.3.5: dependencies: @@ -12569,12 +12817,13 @@ snapshots: ecdsa-sig-formatter: 1.0.11 safe-buffer: 5.2.1 - jwks-rsa@4.0.1: + jwks-rsa@4.1.0: dependencies: '@types/jsonwebtoken': 9.0.10 debug: 4.4.3 - jose: 6.2.3 + jose: 5.10.0 limiter: 1.1.5 + lru-cache: 11.5.1 lru-memoizer: 3.0.0 transitivePeerDependencies: - supports-color @@ -12604,8 +12853,6 @@ snapshots: dependencies: readable-stream: 2.3.8 - leven@3.1.0: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -12672,6 +12919,11 @@ snapshots: loader-runner@4.3.2: {} + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -12750,7 +13002,7 @@ snapshots: lsofi@2.0.0: {} - lucide-react@1.18.0(react@19.2.7): + lucide-react@1.22.0(react@19.2.7): dependencies: react: 19.2.7 @@ -12793,6 +13045,8 @@ snapshots: merge2@1.4.1: {} + meriyah@6.1.4: {} + methods@1.1.2: {} micromatch@4.0.8: @@ -12821,6 +13075,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-function@5.0.1: {} min-indent@1.0.1: {} @@ -12867,7 +13123,7 @@ snapshots: transitivePeerDependencies: - supports-color - motion-dom@12.40.0: + motion-dom@12.42.1: dependencies: motion-utils: 12.39.0 @@ -12887,10 +13143,10 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nan@2.27.0: + nan@2.28.0: optional: true - nanoid@3.3.12: {} + nanoid@3.3.15: {} napi-postinstall@0.3.4: {} @@ -12913,11 +13169,11 @@ snapshots: netmask@2.1.1: {} - next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.36 + baseline-browser-mapping: 2.10.38 caniuse-lite: 1.0.30001799 postcss: 8.4.31 react: 19.2.7 @@ -12933,7 +13189,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.9 '@next/swc-win32-x64-msvc': 16.2.9 '@opentelemetry/api': 1.9.1 - '@playwright/test': 1.61.0 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -12965,25 +13221,25 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - node-gyp@12.4.0: + node-gyp@13.0.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - nopt: 9.0.0 - proc-log: 6.1.0 - semver: 7.8.4 - tar: 7.5.16 + nopt: 10.0.1 + proc-log: 7.0.0 + semver: 7.8.5 + tar: 7.5.17 tinyglobby: 0.2.17 - undici: 6.26.0 - which: 6.0.1 + undici: 6.27.0 + which: 7.0.0 optional: true - node-releases@2.0.47: {} + node-releases@2.0.50: {} - nopt@9.0.0: + nopt@10.0.1: dependencies: - abbrev: 4.0.0 + abbrev: 5.0.0 optional: true normalize-path@3.0.0: {} @@ -13043,7 +13299,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 - obug@2.1.2: {} + obug@2.1.3: {} on-finished@2.3.0: dependencies: @@ -13084,6 +13340,12 @@ snapshots: dependencies: is-wsl: 1.1.0 + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openapi3-ts@3.2.0: dependencies: yaml: 2.9.0 @@ -13131,16 +13393,26 @@ snapshots: p-defer@3.0.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 p-throttle@7.0.0: {} + p-try@2.2.0: {} + pac-proxy-agent@7.2.0: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 @@ -13190,9 +13462,11 @@ snapshots: path-browserify@1.0.1: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} - path-expression-matcher@1.5.0: + path-expression-matcher@1.6.1: optional: true path-key@3.1.1: {} @@ -13219,17 +13493,17 @@ snapshots: pg-cloudflare@1.4.0: optional: true - pg-connection-string@2.13.0: {} + pg-connection-string@2.14.0: {} pg-gateway@0.3.0-beta.4: {} pg-int8@1.0.1: {} - pg-pool@3.14.0(pg@8.21.0): + pg-pool@3.14.0(pg@8.22.0): dependencies: - pg: 8.21.0 + pg: 8.22.0 - pg-protocol@1.14.0: {} + pg-protocol@1.15.0: {} pg-types@2.2.0: dependencies: @@ -13239,11 +13513,11 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.21.0: + pg@8.22.0: dependencies: - pg-connection-string: 2.13.0 - pg-pool: 3.14.0(pg@8.21.0) - pg-protocol: 1.14.0 + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 pg-types: 2.2.0 pgpass: 1.0.5 optionalDependencies: @@ -13261,11 +13535,15 @@ snapshots: pkce-challenge@5.0.1: {} - playwright-core@1.61.0: {} + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + playwright-core@1.61.1: {} - playwright@1.61.0: + playwright@1.61.1: dependencies: - playwright-core: 1.61.0 + playwright-core: 1.61.1 optionalDependencies: fsevents: 2.3.2 @@ -13285,13 +13563,13 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.15: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -13319,7 +13597,7 @@ snapshots: dependencies: parse-ms: 4.0.0 - proc-log@6.1.0: + proc-log@7.0.0: optional: true process-nextick-args@2.0.1: {} @@ -13358,7 +13636,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 25.9.3 + '@types/node': 26.0.1 long: 5.3.2 proxy-addr@2.0.7: @@ -13387,61 +13665,62 @@ snapshots: dependencies: escape-goat: 2.1.1 - qs@6.15.2: + qs@6.15.3: dependencies: + es-define-property: 1.0.1 side-channel: 1.1.1 queue-microtask@1.2.3: {} - radix-ui@1.5.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + radix-ui@1.6.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-accessible-icon': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-aspect-ratio': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-collection': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accessible-icon': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-accordion': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-alert-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-aspect-ratio': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-avatar': 1.2.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-checkbox': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collapsible': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-collection': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-context-menu': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-form': 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-label': 2.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-menubar': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-one-time-password-field': 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-password-toggle-field': 0.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-form': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-hover-card': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-label': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menu': 2.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-menubar': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-navigation-menu': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-one-time-password-field': 0.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-password-toggle-field': 0.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popover': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-popper': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-select': 2.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slider': 1.4.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-toolbar': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-progress': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-radio-group': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-scroll-area': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-select': 2.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-separator': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slider': 1.4.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-switch': 1.3.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tabs': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toast': 1.2.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toggle-group': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-toolbar': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) @@ -13449,7 +13728,7 @@ snapshots: '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -13465,6 +13744,8 @@ snapshots: range-parser@1.2.1: {} + range-parser@1.3.0: {} + raw-body@2.5.3: dependencies: bytes: 3.1.2 @@ -13486,13 +13767,15 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - re2@1.24.1: + re2@1.25.0: dependencies: install-artifact-from-github: 1.6.0 - nan: 2.27.0 - node-gyp: 12.4.0 + nan: 2.28.0 + node-gyp: 13.0.0 optional: true + re2js@0.4.3: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -13665,35 +13948,35 @@ snapshots: dependencies: glob: 10.4.5 - rollup@4.62.0: + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.0 - '@rollup/rollup-android-arm64': 4.62.0 - '@rollup/rollup-darwin-arm64': 4.62.0 - '@rollup/rollup-darwin-x64': 4.62.0 - '@rollup/rollup-freebsd-arm64': 4.62.0 - '@rollup/rollup-freebsd-x64': 4.62.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 - '@rollup/rollup-linux-arm-musleabihf': 4.62.0 - '@rollup/rollup-linux-arm64-gnu': 4.62.0 - '@rollup/rollup-linux-arm64-musl': 4.62.0 - '@rollup/rollup-linux-loong64-gnu': 4.62.0 - '@rollup/rollup-linux-loong64-musl': 4.62.0 - '@rollup/rollup-linux-ppc64-gnu': 4.62.0 - '@rollup/rollup-linux-ppc64-musl': 4.62.0 - '@rollup/rollup-linux-riscv64-gnu': 4.62.0 - '@rollup/rollup-linux-riscv64-musl': 4.62.0 - '@rollup/rollup-linux-s390x-gnu': 4.62.0 - '@rollup/rollup-linux-x64-gnu': 4.62.0 - '@rollup/rollup-linux-x64-musl': 4.62.0 - '@rollup/rollup-openbsd-x64': 4.62.0 - '@rollup/rollup-openharmony-arm64': 4.62.0 - '@rollup/rollup-win32-arm64-msvc': 4.62.0 - '@rollup/rollup-win32-ia32-msvc': 4.62.0 - '@rollup/rollup-win32-x64-gnu': 4.62.0 - '@rollup/rollup-win32-x64-msvc': 4.62.0 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 router@2.2.0: @@ -13756,13 +14039,15 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + semifies@1.0.0: {} + semver-diff@3.1.1: dependencies: semver: 6.3.1 semver@6.3.1: {} - semver@7.8.4: {} + semver@7.8.5: {} send@0.19.2: dependencies: @@ -13793,7 +14078,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -13840,16 +14125,16 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@4.11.0(typescript@5.9.3): + shadcn@4.12.0(typescript@5.9.3): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@dotenvx/dotenvx': 1.71.3 + '@dotenvx/dotenvx': 1.75.1 '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.2 + browserslist: 4.28.4 commander: 14.0.3 cosmiconfig: 9.0.2(typescript@5.9.3) dedent: 1.7.2 @@ -13859,9 +14144,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.5 fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6 kleur: 4.1.5 - node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 postcss: 8.5.15 @@ -13872,6 +14155,7 @@ snapshots: tailwind-merge: 3.6.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 + undici: 7.28.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) @@ -13885,7 +14169,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.4 + semver: 7.8.5 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -13976,8 +14260,6 @@ snapshots: ip-address: 10.2.0 smart-buffer: 4.2.0 - sort-any@4.0.7: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -13989,9 +14271,7 @@ snapshots: split2@4.2.0: {} - sprintf-js@1.0.3: {} - - sql-formatter@15.8.1: + sql-formatter@15.8.2: dependencies: argparse: 2.0.1 nearley: 2.20.1 @@ -14031,7 +14311,7 @@ snapshots: stream-shift@1.0.3: {} - streamx@2.27.0: + streamx@2.28.0: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 @@ -14145,9 +14425,9 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@2.4.0: + strnum@2.4.1: dependencies: - anynum: 1.0.0 + anynum: 1.0.1 optional: true stubs@3.0.0: {} @@ -14179,7 +14459,7 @@ snapshots: router: 2.2.0 update-notifier-cjs: 5.1.7 optionalDependencies: - re2: 1.24.1 + re2: 1.25.0 transitivePeerDependencies: - encoding - supports-color @@ -14203,9 +14483,11 @@ snapshots: symbol-tree@3.2.4: {} + systeminformation@5.31.9: {} + tailwind-merge@3.6.0: {} - tailwindcss@4.3.1: {} + tailwindcss@4.3.2: {} tapable@2.3.3: {} @@ -14214,13 +14496,13 @@ snapshots: b4a: 1.8.1 bare-fs: 4.7.2 fast-fifo: 1.3.2 - streamx: 2.27.0 + streamx: 2.28.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer - react-native-b4a - tar@7.5.16: + tar@7.5.17: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -14258,7 +14540,7 @@ snapshots: teex@1.0.1: dependencies: - streamx: 2.27.0 + streamx: 2.28.0 transitivePeerDependencies: - bare-abort-controller - react-native-b4a @@ -14309,11 +14591,11 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.4.2: {} + tldts-core@7.4.3: {} - tldts@7.4.2: + tldts@7.4.3: dependencies: - tldts-core: 7.4.2 + tldts-core: 7.4.3 tmp@0.2.7: {} @@ -14325,7 +14607,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.4.2 + tldts: 7.4.3 toxic@1.0.1: dependencies: @@ -14429,12 +14711,12 @@ snapshots: dependencies: is-typedarray: 1.0.0 - typescript-eslint@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.61.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: @@ -14451,12 +14733,14 @@ snapshots: undici-types@7.24.6: {} - undici@6.26.0: + undici-types@8.3.0: {} + + undici@6.27.0: optional: true - undici@7.27.2: {} + undici@7.28.0: {} - undici@8.4.1: {} + undici@8.5.0: {} unicode-emoji-modifier-base@1.0.0: {} @@ -14469,7 +14753,7 @@ snapshots: universal-analytics@0.5.4: dependencies: debug: 4.4.3 - uuid: 14.0.0 + uuid: 14.0.1 transitivePeerDependencies: - supports-color @@ -14504,9 +14788,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - update-browserslist-db@1.2.3(browserslist@4.28.2): + update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: - browserslist: 4.28.2 + browserslist: 4.28.4 escalade: 3.2.0 picocolors: 1.1.1 @@ -14525,7 +14809,7 @@ snapshots: pupa: 2.1.1 registry-auth-token: 5.1.1 registry-url: 5.1.0 - semver: 7.8.4 + semver: 7.8.5 semver-diff: 3.1.1 xdg-basedir: 4.0.0 transitivePeerDependencies: @@ -14558,9 +14842,7 @@ snapshots: utils-merge@1.0.1: {} - uuid@11.1.1: {} - - uuid@14.0.0: {} + uuid@14.0.1: {} uuid@9.0.1: {} @@ -14570,33 +14852,33 @@ snapshots: vary@1.1.2: {} - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) - vite: 7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - typescript - vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 - rollup: 4.62.0 + rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 25.9.4 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.48.0 yaml: 2.9.0 - vitest-axe@0.1.0(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))): + vitest-axe@0.1.0(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))): dependencies: aria-query: 5.3.2 axe-core: 4.12.1 @@ -14604,12 +14886,12 @@ snapshots: dom-accessibility-api: 0.5.16 lodash-es: 4.18.1 redent: 3.0.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) - vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -14618,7 +14900,7 @@ snapshots: es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 - obug: 2.1.2 + obug: 2.1.3 pathe: 2.0.3 picomatch: 4.0.4 std-env: 4.1.0 @@ -14626,11 +14908,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.5(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + vite: 7.3.6(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@types/node': 25.9.3 + '@types/node': 25.9.4 jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: - msw @@ -14666,9 +14948,9 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.17.0 acorn-import-phases: 1.0.4(acorn@8.17.0) - browserslist: 4.28.2 + browserslist: 4.28.4 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.0 + enhanced-resolve: 5.24.1 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -14770,7 +15052,7 @@ snapshots: dependencies: isexe: 3.1.5 - which@6.0.1: + which@7.0.0: dependencies: isexe: 4.0.0 optional: true @@ -14883,7 +15165,7 @@ snapshots: yargs-parser@22.0.0: {} - yargs@16.2.0: + yargs@16.2.2: dependencies: cliui: 7.0.4 escalade: 3.2.0 @@ -14893,7 +15175,7 @@ snapshots: y18n: 5.0.8 yargs-parser: 20.2.9 - yargs@17.7.2: + yargs@17.7.3: dependencies: cliui: 8.0.1 escalade: 3.2.0 @@ -14932,6 +15214,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1a164736..c9fffdb6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,4 +23,31 @@ allowBuilds: overrides: glob: 10.4.5 json-ptr: 3.1.0 + jwks-rsa@4>jose: 5.10.0 node-domexception: 2.0.2 + +minimumReleaseAgeExclude: + - '@supabase/auth-js@2.110.0' + - '@supabase/functions-js@2.110.0' + - '@supabase/postgrest-js@2.110.0' + - '@supabase/realtime-js@2.110.0' + - '@supabase/storage-js@2.110.0' + - '@supabase/supabase-js@2.110.0' + - '@tailwindcss/node@4.3.2' + - '@tailwindcss/oxide-android-arm64@4.3.2' + - '@tailwindcss/oxide-darwin-arm64@4.3.2' + - '@tailwindcss/oxide-darwin-x64@4.3.2' + - '@tailwindcss/oxide-freebsd-x64@4.3.2' + - '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2' + - '@tailwindcss/oxide-linux-arm64-gnu@4.3.2' + - '@tailwindcss/oxide-linux-arm64-musl@4.3.2' + - '@tailwindcss/oxide-linux-x64-gnu@4.3.2' + - '@tailwindcss/oxide-linux-x64-musl@4.3.2' + - '@tailwindcss/oxide-wasm32-wasi@4.3.2' + - '@tailwindcss/oxide-win32-arm64-msvc@4.3.2' + - '@tailwindcss/oxide-win32-x64-msvc@4.3.2' + - '@tailwindcss/oxide@4.3.2' + - '@tailwindcss/postcss@4.3.2' + - framer-motion@12.42.1 + - motion-dom@12.42.1 + - tailwindcss@4.3.2 diff --git a/scripts/portless-banner.mjs b/scripts/portless-banner.mjs index 98fe415d..0664f388 100644 --- a/scripts/portless-banner.mjs +++ b/scripts/portless-banner.mjs @@ -2,7 +2,7 @@ const urls = [ ["TRR-APP", process.env.PORTLESS_APP_URL || "https://trr.localhost"], - ["TRR-APP Admin", `${process.env.PORTLESS_ADMIN_URL || "https://admin.trr.localhost"}/admin`], + ["TRR-APP Admin", process.env.PORTLESS_ADMIN_URL || "https://admin.trr.localhost"], ["TRR-Backend", `${process.env.TRR_API_URL || "https://api.trr.localhost"}/health/live`], ]; diff --git a/scripts/test-vercel-guard.sh b/scripts/test-vercel-guard.sh index 462d7741..3e42a196 100755 --- a/scripts/test-vercel-guard.sh +++ b/scripts/test-vercel-guard.sh @@ -5,6 +5,59 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" VERCEL_SH="$SCRIPT_DIR/vercel.sh" +TMP_DIR="$(mktemp -d)" +FAKE_GUARD="$TMP_DIR/vercel-project-guard.py" +NESTED_VERCEL_DIR="$APP_ROOT/apps/web/.vercel" +NESTED_VERCEL_PROJECT="$NESTED_VERCEL_DIR/project.json" +NESTED_VERCEL_BACKUP="" + +if [[ -f "$NESTED_VERCEL_PROJECT" ]]; then + NESTED_VERCEL_BACKUP="$TMP_DIR/nested-project.json.backup" + cp "$NESTED_VERCEL_PROJECT" "$NESTED_VERCEL_BACKUP" +fi + +cleanup() { + if [[ -n "$NESTED_VERCEL_BACKUP" ]]; then + mkdir -p "$NESTED_VERCEL_DIR" + cp "$NESTED_VERCEL_BACKUP" "$NESTED_VERCEL_PROJECT" + else + rm -f "$NESTED_VERCEL_PROJECT" + rmdir "$NESTED_VERCEL_DIR" 2>/dev/null || true + fi + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +cat >"$FAKE_GUARD" <<'PY' +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("--project-dir", required=True) +args = parser.parse_args() + +if args.project_dir.endswith("/apps/web"): + print( + "[vercel-project-guard] ERROR: linked project is web " + "(prj_0nWn8xpm9ikhcvhzE3ma4jUXTe1p); expected trr-app " + "(prj_MHpStkwr26rV5kjt0f80zqhwZpAs). " + "classification=sandbox/stale-nested-project; " + "production env mutation is blocked from this directory." + ) + raise SystemExit(1) + +print("[vercel-project-guard] OK: trr-app (prj_MHpStkwr26rV5kjt0f80zqhwZpAs)") +PY +chmod +x "$FAKE_GUARD" + +prepare_nested_stale_link() { + mkdir -p "$NESTED_VERCEL_DIR" + cat >"$NESTED_VERCEL_PROJECT" <<'JSON' +{"projectName":"web","projectId":"prj_0nWn8xpm9ikhcvhzE3ma4jUXTe1p","orgId":"team_test"} +JSON +} fail() { echo "[test-vercel-guard] FAIL: $*" >&2 @@ -51,46 +104,72 @@ run_fail_contains() { echo "[test-vercel-guard] PASS: $name" } +run_fail_contains \ + "canonical production deploy explicit opt-in" \ + "TRR_VERCEL_ALLOW_PROD=1" \ + bash -c 'cd "$1" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" deploy --prod' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" + run_ok_contains \ - "canonical production deploy guard" \ + "canonical production deploy with explicit opt-in" \ "[vercel-project-guard] OK: trr-app" \ - bash -c 'cd "$1" && TRR_VERCEL_GUARD_ONLY=1 "$2" deploy --prod' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_ALLOW_PROD=1 TRR_VERCEL_GUARD_ONLY=1 "$2" deploy --prod' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" + +run_ok_contains \ + "canonical preview deploy guard" \ + "[vercel-project-guard] OK: trr-app" \ + bash -c 'cd "$1" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" deploy' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" + +run_ok_contains \ + "preview readiness guard" \ + "preview-ready: guard-only accepted" \ + bash -c 'cd "$1" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" preview-ready' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" + +run_ok_contains \ + "link bootstrap already linked guard" \ + "link-trr: TRR-APP is already linked" \ + bash -c 'cd "$1" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" link-trr' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" + +prepare_nested_stale_link run_fail_contains \ "nested production deploy guard" \ "classification=sandbox/stale-nested-project" \ - bash -c 'cd "$1/apps/web" && TRR_VERCEL_GUARD_ONLY=1 "$2" deploy --prod' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1/apps/web" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_ALLOW_PROD=1 TRR_VERCEL_GUARD_ONLY=1 "$2" deploy --prod' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" run_fail_contains \ "nested production deploy guard with global option" \ "classification=sandbox/stale-nested-project" \ - bash -c 'cd "$1/apps/web" && TRR_VERCEL_GUARD_ONLY=1 "$2" --token test-token deploy --prod' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1/apps/web" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_ALLOW_PROD=1 TRR_VERCEL_GUARD_ONLY=1 "$2" --token test-token deploy --prod' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" run_fail_contains \ "root command with nested cwd guard" \ "classification=sandbox/stale-nested-project" \ - bash -c 'cd "$1" && TRR_VERCEL_GUARD_ONLY=1 "$2" --cwd apps/web deploy --prod' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_ALLOW_PROD=1 TRR_VERCEL_GUARD_ONLY=1 "$2" --cwd apps/web deploy --prod' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" run_fail_contains \ "nested env mutation guard" \ "classification=sandbox/stale-nested-project" \ - bash -c 'cd "$1/apps/web" && TRR_VERCEL_GUARD_ONLY=1 "$2" env add TRR_TEST production' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1/apps/web" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" env add TRR_TEST production' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" run_ok_contains \ "nested help bypass" \ "active_project_dir=" \ - bash -c 'cd "$1/apps/web" && TRR_VERCEL_GUARD_ONLY=1 "$2" --help' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1/apps/web" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" --help' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" run_ok_contains \ "nested version bypass" \ "active_project_dir=" \ - bash -c 'cd "$1/apps/web" && TRR_VERCEL_GUARD_ONLY=1 "$2" --version' \ - _ "$APP_ROOT" "$VERCEL_SH" + bash -c 'cd "$1/apps/web" && TRR_VERCEL_PROJECT_GUARD="$3" TRR_VERCEL_GUARD_ONLY=1 "$2" --version' \ + _ "$APP_ROOT" "$VERCEL_SH" "$FAKE_GUARD" echo "[test-vercel-guard] OK" diff --git a/scripts/vercel.sh b/scripts/vercel.sh index e03bb345..0417bc2b 100755 --- a/scripts/vercel.sh +++ b/scripts/vercel.sh @@ -2,10 +2,13 @@ set -euo pipefail -VERCEL_VERSION="${TRR_VERCEL_CLI_VERSION:-50.34.2}" +VERCEL_VERSION="${TRR_VERCEL_CLI_VERSION:-54.15.1}" APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKSPACE_ROOT="$(cd "$APP_ROOT/.." && pwd)" VERCEL_PROJECT_GUARD="${TRR_VERCEL_PROJECT_GUARD:-$WORKSPACE_ROOT/scripts/vercel-project-guard.py}" +TRR_VERCEL_PROJECT_NAME="${TRR_VERCEL_PROJECT_NAME:-trr-app}" +TRR_VERCEL_TEAM_SLUG="${TRR_VERCEL_TEAM_SLUG:-the-reality-reports-projects}" +TRR_VERCEL_TEAM_ID="${TRR_VERCEL_TEAM_ID:-team_EUsG2kN9TAvVDGOu4yZVEoCX}" PNPM_SPEC="$( python3 - "$APP_ROOT/package.json" <<'PY' from __future__ import annotations @@ -25,7 +28,7 @@ PY option_takes_value() { case "$1" in - --cwd | --global-config | --local-config | --scope | --token | -A | -Q | -S | -t) + --cwd | --global-config | --local-config | --scope | --target | --token | -A | -Q | -S | -t) return 0 ;; esac @@ -195,6 +198,395 @@ vercel_project_guard_required() { return 1 } +vercel_deploy_targets_production() { + local arg + local after_deploy=0 + local option_value_for="" + local skip_next=0 + + for arg in "$@"; do + if [[ "$skip_next" -eq 1 ]]; then + if [[ "$after_deploy" -eq 1 && "$option_value_for" == "--target" && "$arg" == "production" ]]; then + return 0 + fi + skip_next=0 + option_value_for="" + continue + fi + + if [[ "$after_deploy" -eq 1 ]]; then + case "$arg" in + --prod | --prod=true | --target=production) + return 0 + ;; + --target) + skip_next=1 + option_value_for="$arg" + ;; + --*=*) + continue + ;; + -*) + if option_takes_value "$arg"; then + skip_next=1 + option_value_for="$arg" + fi + ;; + esac + continue + fi + + case "$arg" in + --*=*) + continue + ;; + -*) + if option_takes_value "$arg"; then + skip_next=1 + option_value_for="$arg" + fi + continue + ;; + esac + + [[ "$arg" == "deploy" ]] && after_deploy=1 + done + + return 1 +} + +run_vercel_cli() { + # Use a repo-controlled CLI version so local builds do not depend on a stale + # globally-installed `vercel` that may reject the workspace's Node 24 baseline. + if [[ "$PNPM_SPEC" == pnpm@* ]] && command -v corepack >/dev/null 2>&1; then + corepack "$PNPM_SPEC" dlx "vercel@${VERCEL_VERSION}" "$@" + return + fi + + pnpm dlx "vercel@${VERCEL_VERSION}" "$@" +} + +run_vercel_cli_with_scope_fallback() { + local status + local output + local tried_primary=0 + + if [[ -n "$TRR_VERCEL_TEAM_SLUG" ]]; then + tried_primary=1 + set +e + output="$(run_vercel_cli "$@" --scope "$TRR_VERCEL_TEAM_SLUG" 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "$output" + return 0 + fi + if [[ "$output" != *"specified scope does not exist"* && "$output" != *"scope does not exist"* ]]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + printf '[vercel.sh] Scope %s was not available to the local CLI; retrying with team id %s.\n' "$TRR_VERCEL_TEAM_SLUG" "$TRR_VERCEL_TEAM_ID" >&2 + fi + + if [[ -n "$TRR_VERCEL_TEAM_ID" && ( "$tried_primary" -eq 0 || "$TRR_VERCEL_TEAM_ID" != "$TRR_VERCEL_TEAM_SLUG" ) ]]; then + run_vercel_cli "$@" --scope "$TRR_VERCEL_TEAM_ID" + return + fi + + run_vercel_cli "$@" +} + +run_vercel_link_with_scope_fallback() { + local status + local output + + set +e + output="$(run_vercel_cli link --yes --cwd "$APP_ROOT" --scope "$TRR_VERCEL_TEAM_SLUG" --project "$TRR_VERCEL_PROJECT_NAME" 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "$output" + return 0 + fi + if [[ "$output" != *"specified scope does not exist"* && "$output" != *"scope does not exist"* && "$output" != *"team does not exist"* ]]; then + printf '%s\n' "$output" >&2 + return "$status" + fi + + printf '[vercel.sh] Team slug %s was not available to the local CLI; retrying link with team id %s.\n' "$TRR_VERCEL_TEAM_SLUG" "$TRR_VERCEL_TEAM_ID" >&2 + run_vercel_cli link --yes --cwd "$APP_ROOT" --scope "$TRR_VERCEL_TEAM_ID" --project "$TRR_VERCEL_PROJECT_NAME" +} + +write_preview_ready_artifact() { + local output_path="$1" + local active_project_dir="$2" + local web_status="$3" + local web_stdout="$4" + local web_stderr="$5" + local speed_status="$6" + local speed_stdout="$7" + local speed_stderr="$8" + local deployments_status="$9" + local deployments_stdout="${10}" + local deployments_stderr="${11}" + + mkdir -p "$(dirname "$output_path")" + python3 - "$output_path" "$active_project_dir" "$TRR_VERCEL_PROJECT_NAME" "$TRR_VERCEL_TEAM_SLUG" "$TRR_VERCEL_TEAM_ID" "$web_status" "$web_stdout" "$web_stderr" "$speed_status" "$speed_stdout" "$speed_stderr" "$deployments_status" "$deployments_stdout" "$deployments_stderr" <<'PY' +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +( + output_path, + active_project_dir, + project_name, + team_slug, + team_id, + web_status, + web_stdout, + web_stderr, + speed_status, + speed_stdout, + speed_stderr, + deployments_status, + deployments_stdout, + deployments_stderr, +) = sys.argv[1:] + +def read_text(path: str) -> str: + return Path(path).read_text(encoding="utf-8") if path else "" + +def parse_json_from_cli_output(output: str) -> object | None: + start = output.find("{") + end = output.rfind("}") + if start == -1 or end == -1 or end < start: + return None + try: + return json.loads(output[start : end + 1]) + except json.JSONDecodeError: + return None + +def latest_deployment_from_output(output: str) -> dict[str, object] | None: + parsed = parse_json_from_cli_output(output) + if not isinstance(parsed, dict): + return None + deployments = parsed.get("deployments") + if not isinstance(deployments, list) or not deployments: + return None + first = deployments[0] + if not isinstance(first, dict): + return None + url = first.get("url") + if not isinstance(url, str) or not url: + return None + normalized_url = url if url.startswith(("http://", "https://")) else f"https://{url}" + return { + "url": normalized_url, + "rawUrl": url, + "state": first.get("state") if isinstance(first.get("state"), str) else None, + "target": first.get("target") if isinstance(first.get("target"), str) else None, + "createdAt": first.get("createdAt") if isinstance(first.get("createdAt"), int) else None, + "ready": first.get("ready") if isinstance(first.get("ready"), int) else None, + "gitRef": first.get("meta", {}).get("githubCommitRef") if isinstance(first.get("meta"), dict) else None, + "gitSha": first.get("meta", {}).get("githubCommitSha") if isinstance(first.get("meta"), dict) else None, + } + +deployments_output = read_text(deployments_stdout) +latest_deployment = latest_deployment_from_output(deployments_output) +payload = { + "generatedAt": datetime.now(timezone.utc).isoformat(), + "projectName": project_name, + "teamSlug": team_slug, + "teamId": team_id, + "activeProjectDir": active_project_dir, + "latestDeploymentUrl": latest_deployment["url"] if latest_deployment else None, + "latestDeployment": latest_deployment, + "checks": { + "webAnalytics": { + "status": int(web_status), + "stdout": read_text(web_stdout), + "stderr": read_text(web_stderr), + }, + "speedInsights": { + "status": int(speed_status), + "stdout": read_text(speed_stdout), + "stderr": read_text(speed_stderr), + }, + "deployments": { + "status": int(deployments_status), + "stdout": deployments_output, + "stderr": read_text(deployments_stderr), + }, + }, +} +Path(output_path).write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY +} + +default_preview_ready_output() { + local timestamp + + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + printf '%s\n' "$WORKSPACE_ROOT/.logs/workspace/vercel-preview-ready/$timestamp.json" +} + +run_preview_ready() { + local active_project_dir + local output_path + local tmp_dir + local web_stdout + local web_stderr + local speed_stdout + local speed_stderr + local deployments_stdout + local deployments_stderr + local web_status + local speed_status + local deployments_status + + output_path="${TRR_VERCEL_PREVIEW_READY_OUTPUT:-$(default_preview_ready_output)}" + + while [[ "$#" -gt 0 ]]; do + case "$1" in + --output) + if [[ -z "${2:-}" ]]; then + echo "[vercel.sh] preview-ready --output requires a path." >&2 + exit 2 + fi + output_path="$2" + shift 2 + ;; + --output=*) + output_path="${1#--output=}" + shift + ;; + *) + echo "[vercel.sh] preview-ready does not accept positional arguments: $1" >&2 + echo "[vercel.sh] Configure with TRR_VERCEL_PROJECT_NAME, TRR_VERCEL_TEAM_SLUG, and TRR_VERCEL_TEAM_ID if needed." >&2 + exit 2 + ;; + esac + done + + active_project_dir="$(find_active_vercel_project_dir "$APP_ROOT")" + if ! python3 "$VERCEL_PROJECT_GUARD" --project-dir "$active_project_dir"; then + echo "[vercel.sh] Preview readiness blocked: TRR-APP is not linked to the trr-app Vercel project of record." >&2 + echo "[vercel.sh] Link from $APP_ROOT, then rerun: TRR-APP/scripts/vercel.sh link-trr && TRR-APP/scripts/vercel.sh preview-ready" >&2 + exit 1 + fi + + if [[ "${TRR_VERCEL_GUARD_ONLY:-}" == "1" ]]; then + echo "[vercel.sh] preview-ready: guard-only accepted; active_project_dir=$active_project_dir" + exit 0 + fi + + tmp_dir="$(mktemp -d)" + web_stdout="$tmp_dir/web-analytics.stdout" + web_stderr="$tmp_dir/web-analytics.stderr" + speed_stdout="$tmp_dir/speed-insights.stdout" + speed_stderr="$tmp_dir/speed-insights.stderr" + deployments_stdout="$tmp_dir/deployments.stdout" + deployments_stderr="$tmp_dir/deployments.stderr" + + echo "[vercel.sh] preview-ready: checking/enabling Web Analytics for $TRR_VERCEL_PROJECT_NAME." >&2 + set +e + run_vercel_cli_with_scope_fallback project web-analytics "$TRR_VERCEL_PROJECT_NAME" --format json >"$web_stdout" 2>"$web_stderr" + web_status=$? + set -e + cat "$web_stdout" + cat "$web_stderr" >&2 + + echo "[vercel.sh] preview-ready: checking/enabling Speed Insights for $TRR_VERCEL_PROJECT_NAME." >&2 + set +e + run_vercel_cli_with_scope_fallback project speed-insights "$TRR_VERCEL_PROJECT_NAME" --format json >"$speed_stdout" 2>"$speed_stderr" + speed_status=$? + set -e + cat "$speed_stdout" + cat "$speed_stderr" >&2 + + echo "[vercel.sh] preview-ready: checking latest deployment URL for $TRR_VERCEL_PROJECT_NAME." >&2 + set +e + run_vercel_cli_with_scope_fallback list "$TRR_VERCEL_PROJECT_NAME" --format json >"$deployments_stdout" 2>"$deployments_stderr" + deployments_status=$? + set -e + cat "$deployments_stderr" >&2 + + write_preview_ready_artifact "$output_path" "$active_project_dir" "$web_status" "$web_stdout" "$web_stderr" "$speed_status" "$speed_stdout" "$speed_stderr" "$deployments_status" "$deployments_stdout" "$deployments_stderr" + cp "$output_path" "$WORKSPACE_ROOT/.logs/workspace/vercel-preview-ready/latest.json" + rm -rf "$tmp_dir" + + echo "[vercel.sh] preview-ready artifact: $output_path" + + if [[ "$web_status" -ne 0 || "$speed_status" -ne 0 || "$deployments_status" -ne 0 ]]; then + echo "[vercel.sh] preview-ready failed; see artifact for command output." >&2 + exit 1 + fi + echo "[vercel.sh] preview-ready: project link, Web Analytics, Speed Insights, and latest deployment checks completed." +} + +run_link_trr() { + if [[ "$#" -gt 0 ]]; then + echo "[vercel.sh] link-trr does not accept positional arguments." >&2 + exit 2 + fi + + if python3 "$VERCEL_PROJECT_GUARD" --project-dir "$APP_ROOT" >/dev/null 2>&1; then + echo "[vercel.sh] link-trr: TRR-APP is already linked to $TRR_VERCEL_PROJECT_NAME." + return 0 + fi + + if [[ "${TRR_VERCEL_GUARD_ONLY:-}" == "1" ]]; then + echo "[vercel.sh] link-trr: guard-only accepted; would link $APP_ROOT to $TRR_VERCEL_PROJECT_NAME." + return 0 + fi + + run_vercel_link_with_scope_fallback + python3 "$VERCEL_PROJECT_GUARD" --project-dir "$APP_ROOT" +} + +run_cleanup_doctor() { + local arg + local has_scan_root=0 + + for arg in "$@"; do + case "$arg" in + --scan-root | --scan-root=*) + has_scan_root=1 + ;; + esac + done + + if [[ "$has_scan_root" -eq 1 ]]; then + exec python3 "$WORKSPACE_ROOT/scripts/vercel-cleanup-doctor.py" --expected-name "$TRR_VERCEL_PROJECT_NAME" "$@" + fi + + exec python3 "$WORKSPACE_ROOT/scripts/vercel-cleanup-doctor.py" --scan-root "$APP_ROOT" --expected-name "$TRR_VERCEL_PROJECT_NAME" "$@" +} + +case "${1:-}" in + auth-doctor) + shift + exec python3 "$WORKSPACE_ROOT/scripts/vercel-auth-doctor.py" --app-root "$APP_ROOT" --project-name "$TRR_VERCEL_PROJECT_NAME" --team-slug "$TRR_VERCEL_TEAM_SLUG" --team-id "$TRR_VERCEL_TEAM_ID" "$@" + ;; + cleanup-doctor) + shift + run_cleanup_doctor "$@" + ;; + link-trr) + shift + run_link_trr "$@" + exit 0 + ;; + preview-ready) + shift + run_preview_ready "$@" + exit 0 + ;; +esac + if vercel_project_guard_required "$@"; then active_project_dir="$(find_active_vercel_project_dir "$(vercel_invocation_cwd "$@")")" if ! python3 "$VERCEL_PROJECT_GUARD" --project-dir "$active_project_dir"; then @@ -206,15 +598,15 @@ else active_project_dir="" fi +if vercel_deploy_targets_production "$@" && [[ "${TRR_VERCEL_ALLOW_PROD:-}" != "1" ]]; then + echo "[vercel.sh] Refusing production deploy without TRR_VERCEL_ALLOW_PROD=1." >&2 + echo "[vercel.sh] Use preview deploys for readiness checks; production deploys need explicit current approval." >&2 + exit 1 +fi + if [[ "${TRR_VERCEL_GUARD_ONLY:-}" == "1" ]]; then echo "[vercel.sh] guard-only: command accepted; active_project_dir=$active_project_dir" exit 0 fi -# Use a repo-controlled CLI version so local builds do not depend on a stale -# globally-installed `vercel` that may reject the workspace's Node 24 baseline. -if [[ "$PNPM_SPEC" == pnpm@* ]] && command -v corepack >/dev/null 2>&1; then - exec corepack "$PNPM_SPEC" dlx "vercel@${VERCEL_VERSION}" "$@" -fi - -exec pnpm dlx "vercel@${VERCEL_VERSION}" "$@" +run_vercel_cli "$@"