From 6dff925ec9f87215cd98f562e9056e1204ff6985 Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Wed, 17 Jun 2026 16:59:15 -0600 Subject: [PATCH] feat: organizer file-drop import agent (volunteers/judges MVP) Drag a roster onto an organizer page; an agent reads it, matches against existing volunteers, drafts proposals, and writes nothing until the organizer confirms once. Clones the AI Judge Scheduler "model proposes -> server validates -> organizer confirms" architecture. Backend - agent_import_runs table + id generator + AI_FILE_IMPORT feature flag - OrganizerFileImportAgent Durable Object (proposal-only tools, refine loop, intent disambiguation); namespace + binding in alchemy.run.ts; WS routing + re-export in server.ts; Env augmentation in env.d.ts - Private upload route /api/agent-import/upload (unguessable R2 key, no public URL, sha-256 checksum) - PII stays server-side - Shared schemas/parse/validate under src/lib/organizer-file-import (pure, unit-tested); server-only access (4-layer entitlement gating) + context loaders under src/server/organizer-file-import - Server fns: createImportRunFn, loadFileImportContextFn (paywall probe), applyOrganizerImportFn (only write path, reuses inviteUserToTeam, records applied entities), undoImportFn UI - ImportShell mounted in the organizer layout: full-page drop overlay + persistent click-to-upload dock, gated to volunteers/judges by usePageIntent - ImportReviewDrawer: streamed proposals as the preview, per-row exclude, refine-by-prompt, single confirm, receipt + undo Tests: parse + validate unit tests (18 passing). Type-check + biome clean. lat.md updated (architecture + organizer-dashboard); lat check passes. Events/event-detail import, inline-diff surface, and XLSX fixture tests are scoped as documented follow-ups. Requires `pnpm db:push` (new table) and seeding the AI_FILE_IMPORT feature before enabling for a team. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/wodsmith-start/alchemy.run.ts | 19 + apps/wodsmith-start/package.json | 3 + .../src/agents/organizer-file-import-agent.ts | 559 ++++++++++++++++++ .../organizer-import/import-review-drawer.tsx | 419 +++++++++++++ .../organizer-import/import-shell.tsx | 155 +++++ .../organizer-import/use-page-intent.ts | 37 ++ apps/wodsmith-start/src/config/features.ts | 1 + apps/wodsmith-start/src/db/schema.ts | 1 + .../src/db/schemas/agent-imports.ts | 103 ++++ apps/wodsmith-start/src/db/schemas/common.ts | 3 + .../src/lib/organizer-file-import/parse.ts | 138 +++++ .../src/lib/organizer-file-import/schemas.ts | 259 ++++++++ .../src/lib/organizer-file-import/validate.ts | 82 +++ apps/wodsmith-start/src/routeTree.gen.ts | 21 + .../src/routes/api/agent-import/upload.ts | 145 +++++ .../compete/organizer/$competitionId.tsx | 11 +- .../server-fns/organizer-file-import-fns.ts | 349 +++++++++++ apps/wodsmith-start/src/server.ts | 21 + .../server/organizer-file-import/access.ts | 243 ++++++++ .../server/organizer-file-import/context.ts | 170 ++++++ apps/wodsmith-start/src/types/env.d.ts | 1 + .../lib/organizer-file-import/parse.test.ts | 90 +++ .../organizer-file-import/validate.test.ts | 104 ++++ lat.md/architecture.md | 12 + lat.md/organizer-dashboard.md | 2 + pnpm-lock.yaml | 194 +++--- 26 files changed, 3043 insertions(+), 99 deletions(-) create mode 100644 apps/wodsmith-start/src/agents/organizer-file-import-agent.ts create mode 100644 apps/wodsmith-start/src/components/organizer-import/import-review-drawer.tsx create mode 100644 apps/wodsmith-start/src/components/organizer-import/import-shell.tsx create mode 100644 apps/wodsmith-start/src/components/organizer-import/use-page-intent.ts create mode 100644 apps/wodsmith-start/src/db/schemas/agent-imports.ts create mode 100644 apps/wodsmith-start/src/lib/organizer-file-import/parse.ts create mode 100644 apps/wodsmith-start/src/lib/organizer-file-import/schemas.ts create mode 100644 apps/wodsmith-start/src/lib/organizer-file-import/validate.ts create mode 100644 apps/wodsmith-start/src/routes/api/agent-import/upload.ts create mode 100644 apps/wodsmith-start/src/server-fns/organizer-file-import-fns.ts create mode 100644 apps/wodsmith-start/src/server/organizer-file-import/access.ts create mode 100644 apps/wodsmith-start/src/server/organizer-file-import/context.ts create mode 100644 apps/wodsmith-start/test/lib/organizer-file-import/parse.test.ts create mode 100644 apps/wodsmith-start/test/lib/organizer-file-import/validate.test.ts diff --git a/apps/wodsmith-start/alchemy.run.ts b/apps/wodsmith-start/alchemy.run.ts index 0ee7226c7..8813b97f9 100644 --- a/apps/wodsmith-start/alchemy.run.ts +++ b/apps/wodsmith-start/alchemy.run.ts @@ -527,6 +527,23 @@ const judgeSchedulerAgent = DurableObjectNamespace("judge-scheduler-agent", { sqlite: true, }) +/** + * Durable Object namespace for the organizer file-drop import agent. + * + * Each dropped file is an isolated session keyed by `${importRunId}__${userId}` + * (a fresh ULID per drop) so concurrent imports never collide and a + * reconnecting organizer reattaches to the same in-flight proposal stream. + * + * @see src/agents/organizer-file-import-agent.ts + */ +const organizerFileImportAgent = DurableObjectNamespace( + "organizer-file-import-agent", + { + className: "OrganizerFileImportAgent", + sqlite: true, + }, +) + /** * Cloudflare Workers AI binding for built-in LLM inference. * @@ -658,6 +675,8 @@ const website = await TanStackStart("app", { BROADCAST_EMAIL_QUEUE: broadcastEmailQueue, /** Durable Object namespace for the AI judge-scheduling agent */ JUDGE_SCHEDULER_AGENT: judgeSchedulerAgent, + /** Durable Object namespace for the organizer file-drop import agent */ + ORGANIZER_FILE_IMPORT_AGENT: organizerFileImportAgent, /** Cloudflare Workers AI binding for LLM inference */ AI: aiBinding, /** diff --git a/apps/wodsmith-start/package.json b/apps/wodsmith-start/package.json index 834491ed2..f8580465c 100644 --- a/apps/wodsmith-start/package.json +++ b/apps/wodsmith-start/package.json @@ -108,6 +108,7 @@ "lucide-react": "^0.544.0", "ms": "^2.1.3", "mysql2": "^3.16.2", + "papaparse": "^5.5.3", "posthog-js": "^1.310.1", "posthog-node": "^5.18.0", "react": "^19.2.3", @@ -126,6 +127,7 @@ "ua-parser-js": "^2.0.3", "ulid": "^3.0.2", "workers-ai-provider": "^3.1.14", + "xlsx": "^0.18.5", "zod": "^4.2.1", "zustand": "^5.0.5" }, @@ -140,6 +142,7 @@ "@testing-library/react": "^16.2.0", "@types/ms": "^0.7.34", "@types/node": "^22.19.3", + "@types/papaparse": "^5.5.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@types/ua-parser-js": "^0.7.39", diff --git a/apps/wodsmith-start/src/agents/organizer-file-import-agent.ts b/apps/wodsmith-start/src/agents/organizer-file-import-agent.ts new file mode 100644 index 000000000..1e021a319 --- /dev/null +++ b/apps/wodsmith-start/src/agents/organizer-file-import-agent.ts @@ -0,0 +1,559 @@ +import "server-only" + +import { createId } from "@paralleldrive/cuid2" +import { Agent, callable } from "agents" +import { generateText, stepCountIs, type Tool, tool } from "ai" +import { createAiGateway } from "ai-gateway-provider" +import { createUnified } from "ai-gateway-provider/providers/unified" +import { z } from "zod" +import { logError, logInfo } from "@/lib/logging" +import { + type ActivityEntry, + type AgentState, + askClarificationInputSchema, + initialAgentState, + MAX_THINKING_LOG_ENTRIES, + markCompleteInputSchema, + markImportAppliedInputSchema, + proposeVolunteerInputSchema, + refineInputSchema, + revokeProposalInputSchema, + startImportInputSchema, + type VolunteerProposal, +} from "@/lib/organizer-file-import/schemas" +import { + classifyVolunteer, + type ExistingVolunteer, +} from "@/lib/organizer-file-import/validate" +import { requireFileImportAgentAccess } from "@/server/organizer-file-import/access" +import { + type FileImportPageContext, + loadExistingVolunteers, + loadPageContext, + readImportFile, +} from "@/server/organizer-file-import/context" +import type { ParsedTable } from "@/lib/organizer-file-import/parse" + +/** + * Workers AI model id addressed via the AI Gateway's unified adapter — same + * model the judge-scheduling agent uses. + */ +const MODEL_ID = "workers-ai/@cf/moonshotai/kimi-k2.6" +const MAX_STEPS = 32 + +const SYSTEM_PROMPT = `You are an assistant that imports volunteers and judges into a Functional Fitness competition from a file an organizer dropped onto a page. You read the file's already-parsed rows, map columns, and DRAFT proposals. + +CRITICAL — you may classify, extract, validate, and PROPOSE only. You NEVER write anything, never send invitations, and must never imply that anyone has been added. A human organizer reviews your draft and confirms once; only then does anything happen. + +Workflow: +- Always start by calling get_page_context and get_import_table exactly once each. +- The page tells you the intent: 'volunteers' / 'judges' = import people as volunteers; 'judges' means default roleTypes to ["judge"]. +- For EACH person row in the table, call propose_volunteer exactly once. Generate a fresh proposalId per row (e.g. "v1", "v2") and set rowKey to a stable identifier from the row (the email if present, else the row's name). +- Map columns intelligently: Name/Full Name → name; Email/E-mail → email; Phone/Mobile → phone; Role/Position/Job → roleTypes; Credentials/Certifications/Cert → credentials; Shirt/Size → shirtSize; Availability → availability. +- roleTypes MUST come from this set only: judge, head_judge, equipment, medical, check_in, staff, scorekeeper, emcee, floor_manager, media, general, athlete_control, equipment_team. Map free text (e.g. "head judge" → head_judge, "coach" → judge, "EMT" → medical). If unsure, use ["general"], or ["judge"] on the judges page. +- action: use "create" for a new person to invite. If a row has no usable email, use "needs_input" (the system also flags this — an invitation can't be sent without an email). +- availability must be one of: morning, afternoon, all_day, or omit it. +- Set confidence (high/medium/low) by how cleanly the row mapped. Keep rationale under 240 chars, concrete (e.g. "Mapped Name+Email; role from 'Head Judge'"). +- Duplicate detection against existing volunteers happens automatically server-side — still propose the row; it will be marked as a match and excluded by default. +- INTENT CHECK: if the file clearly is NOT a roster of people (e.g. it lists workouts/events, scores, or schedule rows), do NOT invent volunteers. Call ask_clarification asking whether they meant a different page, and stop. +- When every person row has been proposed once, call mark_complete with a 1-2 sentence summary. Do not invent extra rows. +- Do not propose the same person (same email) twice.` + +interface ImportRunContext { + scope: { competitionTeamId: string | null } + pageContext: FileImportPageContext + table: ParsedTable + truncated: boolean + existingVolunteers: ExistingVolunteer[] +} + +/** + * Durable-Object-backed agent that drafts volunteer/judge imports from a + * dropped file. `state.volunteerProposals` is the UI-watched source of truth; + * every setState is broadcast to connected clients over WebSocket. The agent + * proposes only — all writes happen later in applyOrganizerImportFn after the + * organizer confirms. Mirrors JudgeSchedulerAgent. + */ +export class OrganizerFileImportAgent extends Agent { + initialState: AgentState = initialAgentState + + #abortController: AbortController | null = null + + logActivity(kind: ActivityEntry["kind"], message: string): void { + const entry: ActivityEntry = { + id: createId(), + timestamp: Date.now(), + kind, + message, + } + const next = [...this.state.thinkingLog, entry] + const trimmed = + next.length > MAX_THINKING_LOG_ENTRIES + ? next.slice(next.length - MAX_THINKING_LOG_ENTRIES) + : next + this.setState({ ...this.state, thinkingLog: trimmed }) + } + + @callable() + async start(rawInput: unknown): Promise<{ ok: boolean; error?: string }> { + const runStartedAt = Date.now() + try { + const input = startImportInputSchema.parse(rawInput) + logInfo({ + message: "[ImportAgent] start invoked", + attributes: { + importRunId: input.importRunId, + competitionId: input.competitionId, + routeKind: input.routeKind, + }, + }) + + const userId = getUserIdFromAgentName(this.name) + const scope = await requireFileImportAgentAccess( + { + competitionId: input.competitionId, + routeKind: input.routeKind, + eventId: input.eventId ?? null, + }, + userId, + ) + + this.setState({ + ...initialAgentState, + importRunId: input.importRunId, + competitionId: input.competitionId, + eventId: input.eventId ?? null, + routeKind: input.routeKind, + status: "parsing", + startedAt: Date.now(), + }) + this.logActivity("thinking", "Reading the dropped file…") + + const ctx = await this.#loadContext(input.importRunId, scope, input) + if (ctx.table.warnings.length > 0) { + this.setState({ ...this.state, parseWarnings: ctx.table.warnings }) + } + this.logActivity( + "thinking", + `Parsed ${ctx.table.rows.length} row${ + ctx.table.rows.length === 1 ? "" : "s" + } with columns: ${ctx.table.headers.join(", ") || "(none detected)"}.`, + ) + + await this.#generate( + ctx, + buildKickoffPrompt(ctx), + runStartedAt, + input.importRunId, + ) + return { ok: true } + } catch (err) { + return this.#handleRunError(err, runStartedAt) + } finally { + this.#abortController = null + } + } + + @callable() + async refine(rawInput: unknown): Promise<{ ok: boolean; error?: string }> { + const runStartedAt = Date.now() + try { + const { instruction } = refineInputSchema.parse(rawInput) + if ( + !this.state.importRunId || + !this.state.competitionId || + !this.state.routeKind + ) { + throw new Error("Nothing to refine yet — run an import first.") + } + + const userId = getUserIdFromAgentName(this.name) + const scope = await requireFileImportAgentAccess( + { + competitionId: this.state.competitionId, + routeKind: this.state.routeKind, + eventId: this.state.eventId ?? undefined, + }, + userId, + ) + + this.setState({ + ...this.state, + status: "thinking", + summary: null, + errorMessage: null, + completedAt: null, + }) + this.logActivity("thinking", `Refining draft: "${instruction}"`) + + const ctx = await this.#loadContext(this.state.importRunId, scope, { + competitionId: this.state.competitionId, + routeKind: this.state.routeKind, + eventId: this.state.eventId ?? undefined, + }) + + await this.#generate( + ctx, + buildRefinePrompt(this.state.volunteerProposals, instruction), + runStartedAt, + this.state.importRunId, + ) + return { ok: true } + } catch (err) { + return this.#handleRunError(err, runStartedAt) + } finally { + this.#abortController = null + } + } + + @callable() + stop(): { ok: boolean; running: boolean } { + if (!this.#abortController) { + return { ok: false, running: false } + } + this.#abortController.abort() + return { ok: true, running: true } + } + + @callable() + reset(): { ok: true } { + this.setState(initialAgentState) + return { ok: true } + } + + /** + * Flip the given proposalIds to accepted and drop them from the review + * list. Called by the UI after applyOrganizerImportFn persists them, so a + * subsequent refine won't re-surface already-applied rows. + */ + @callable() + markApplied(rawInput: unknown): { ok: true; appliedCount: number } { + const input = markImportAppliedInputSchema.parse(rawInput) + const appliedSet = new Set(input.proposalIds) + const remaining = this.state.volunteerProposals.filter( + (p) => !appliedSet.has(p.proposalId), + ) + this.setState({ + ...this.state, + volunteerProposals: remaining, + status: remaining.length === 0 ? "idle" : this.state.status, + }) + this.logActivity( + "done", + `Organizer applied ${input.proposalIds.length} import${ + input.proposalIds.length === 1 ? "" : "s" + }.`, + ) + return { ok: true, appliedCount: input.proposalIds.length } + } + + async #loadContext( + importRunId: string, + scope: { competitionTeamId: string | null }, + page: { competitionId: string; routeKind: string; eventId?: string | null }, + ): Promise { + const [{ table, truncated }, pageContext, existingVolunteers] = + await Promise.all([ + readImportFile(importRunId), + loadPageContext({ + competitionId: page.competitionId, + routeKind: page.routeKind, + eventId: page.eventId ?? null, + }), + loadExistingVolunteers(scope.competitionTeamId), + ]) + return { scope, pageContext, table, truncated, existingVolunteers } + } + + async #generate( + ctx: ImportRunContext, + prompt: string, + runStartedAt: number, + importRunId: string, + ): Promise { + this.setState({ ...this.state, status: "thinking" }) + + const gatewayBinding = this.env.AI.gateway(this.env.CF_AIG_GATEWAY) + const aiGateway = createAiGateway({ + binding: { + run: (data) => + gatewayBinding.run(data as Parameters[0]), + }, + }) + const unified = createUnified() + this.#abortController = new AbortController() + + const result = await generateText({ + model: aiGateway(unified(MODEL_ID)), + system: SYSTEM_PROMPT, + prompt, + tools: buildTools(this, ctx), + stopWhen: stepCountIs(MAX_STEPS), + abortSignal: this.#abortController.signal, + }) + + logInfo({ + message: "[ImportAgent] generateText finished", + attributes: { + importRunId, + durationMs: Date.now() - runStartedAt, + proposalCount: this.state.volunteerProposals.length, + stepCount: result.steps?.length ?? 0, + finishReason: result.finishReason, + status: this.state.status, + }, + }) + + if (this.state.status !== "proposals_ready") { + this.setState({ + ...this.state, + status: "proposals_ready", + summary: result.text || this.state.summary || "Draft ready for review.", + completedAt: Date.now(), + }) + this.logActivity( + "done", + result.text || this.state.summary || "Draft ready for review.", + ) + } + } + + #handleRunError( + err: unknown, + runStartedAt: number, + ): { ok: boolean; error?: string } { + const message = err instanceof Error ? err.message : String(err) + const isAbort = + err instanceof Error && + (err.name === "AbortError" || message.toLowerCase().includes("abort")) + if (isAbort) { + this.logActivity("thinking", "Run stopped by organizer.") + this.setState({ + ...this.state, + status: + this.state.volunteerProposals.length > 0 ? "proposals_ready" : "idle", + summary: null, + errorMessage: null, + completedAt: Date.now(), + }) + return { ok: true } + } + this.logActivity("error", `Run failed: ${message}`) + logError({ + message: "[ImportAgent] run failed", + error: err, + attributes: { + durationMs: Date.now() - runStartedAt, + proposalCount: this.state.volunteerProposals.length, + }, + }) + this.setState({ + ...this.state, + status: "error", + errorMessage: message, + completedAt: Date.now(), + }) + return { ok: false, error: message } + } +} + +function getUserIdFromAgentName(name: string): string { + const match = /^[a-z0-9_-]{1,128}__([a-z0-9_-]{1,128})$/i.exec(name) + if (!match?.[1]) { + throw new Error("Invalid agent name") + } + return match[1] +} + +function buildKickoffPrompt(ctx: ImportRunContext): string { + const { pageContext, table, truncated, existingVolunteers } = ctx + const lines = [ + `Import volunteers for "${pageContext.competitionName}" (page: ${pageContext.routeKind}).`, + `The dropped file parsed to ${table.rows.length} data row${ + table.rows.length === 1 ? "" : "s" + }${truncated ? " (showing the first 200)" : ""} with columns: ${ + table.headers.join(", ") || "(none detected)" + }.`, + `There ${existingVolunteers.length === 1 ? "is" : "are"} ${existingVolunteers.length} existing volunteer${ + existingVolunteers.length === 1 ? "" : "s" + }; duplicates are detected automatically.`, + "Call get_page_context and get_import_table first, then propose one volunteer per person row, and finish with mark_complete.", + ] + return lines.join("\n") +} + +function buildRefinePrompt( + current: VolunteerProposal[], + instruction: string, +): string { + const summary = current.map((p) => ({ + proposalId: p.proposalId, + name: p.name, + email: p.email, + roleTypes: p.roleTypes, + action: p.action, + })) + return [ + `You previously drafted ${current.length} volunteer proposal${ + current.length === 1 ? "" : "s" + }. The organizer wants to refine the draft:`, + `"${instruction}"`, + `Current proposals (JSON): ${JSON.stringify(summary)}`, + "Apply the instruction by editing the draft in place: call revoke_proposal for any to remove, and propose_volunteer (reuse the SAME proposalId to replace one) for changes. Call get_import_table if you need the original rows. Finish with mark_complete.", + ].join("\n") +} + +function buildTools( + agent: OrganizerFileImportAgent, + ctx: ImportRunContext, +): Record { + const { pageContext, table, truncated, existingVolunteers } = ctx + + return { + get_page_context: tool({ + description: + "Return the competition name, the page the file was dropped on (routeKind), and any event id. Call once per run.", + inputSchema: z.object({}), + execute: async () => { + agent.logActivity("tool", "Checked which page the file was dropped on.") + return pageContext + }, + }), + + get_import_table: tool({ + description: + "Return the parsed file: detected headers and up to the first 200 data rows. Call once per run, then map each row to a volunteer.", + inputSchema: z.object({}), + execute: async () => { + agent.logActivity( + "tool", + `Read the file — ${table.rows.length} row${ + table.rows.length === 1 ? "" : "s" + }, ${table.headers.length} column${table.headers.length === 1 ? "" : "s"}.`, + ) + return { + headers: table.headers, + rows: table.rows, + rowCount: table.rows.length, + truncated, + } + }, + }), + + propose_volunteer: tool({ + description: + "Draft ONE volunteer/judge to import. The organizer sees it stream in immediately. Duplicate detection and no-email warnings are attached automatically.", + inputSchema: proposeVolunteerInputSchema, + execute: async (input) => { + const classification = classifyVolunteer( + { email: input.email, name: input.name }, + existingVolunteers, + ) + const warnings = [...classification.warnings] + if (classification.matchKind === "existing_member") { + warnings.push("Already a volunteer — will be skipped.") + } else if (classification.matchKind === "existing_invite") { + warnings.push("Already invited — will be skipped.") + } + + const merged: VolunteerProposal = { + ...input, + matchKind: classification.matchKind, + matchedMembershipId: classification.matchedMembershipId, + warnings: dedupeStrings(warnings).slice(0, 10), + status: "pending", + } + + const next = agent.state.volunteerProposals.filter( + (p) => p.proposalId !== merged.proposalId, + ) + next.push(merged) + agent.setState({ ...agent.state, volunteerProposals: next }) + + const label = merged.name || merged.email || merged.rowKey + if (merged.matchKind === "new") { + agent.logActivity( + "proposed", + `Proposed ${label}${ + merged.warnings.length > 0 + ? ` (${merged.warnings.length} warning${merged.warnings.length === 1 ? "" : "s"})` + : "" + }.`, + ) + } else { + agent.logActivity( + "skipped", + `${label} ${ + merged.matchKind === "existing_member" + ? "is already a volunteer" + : "was already invited" + } — flagged as a duplicate.`, + ) + } + return { + status: "recorded" as const, + matchKind: merged.matchKind, + warnings: merged.warnings, + } + }, + }), + + revoke_proposal: tool({ + description: + "Withdraw a previously drafted volunteer by proposalId. Use when reconsidering or replacing.", + inputSchema: revokeProposalInputSchema, + execute: async (input) => { + const before = agent.state.volunteerProposals.length + const next = agent.state.volunteerProposals.filter( + (p) => p.proposalId !== input.proposalId, + ) + agent.setState({ ...agent.state, volunteerProposals: next }) + agent.logActivity( + "thinking", + `Withdrew ${input.proposalId} — ${input.reason}`, + ) + return { + status: "revoked" as const, + removed: before - next.length, + } + }, + }), + + ask_clarification: tool({ + description: + "Ask the organizer a question when the file doesn't match the page (e.g. a roster dropped on Events). Sets a banner and stops proposing.", + inputSchema: askClarificationInputSchema, + execute: async (input) => { + agent.setState({ + ...agent.state, + clarification: { + question: input.question, + suggestedRouteKind: input.suggestedRouteKind, + }, + }) + agent.logActivity("thinking", `Asked: ${input.question}`) + return { status: "asked" as const } + }, + }), + + mark_complete: tool({ + description: + "Mark the draft finished with a 1-2 sentence summary for the organizer.", + inputSchema: markCompleteInputSchema, + execute: async (input) => { + agent.setState({ + ...agent.state, + status: "proposals_ready", + summary: input.summary, + completedAt: Date.now(), + }) + agent.logActivity("done", input.summary) + return { status: "complete" as const } + }, + }), + } +} + +function dedupeStrings(input: string[]): string[] { + return Array.from(new Set(input)) +} diff --git a/apps/wodsmith-start/src/components/organizer-import/import-review-drawer.tsx b/apps/wodsmith-start/src/components/organizer-import/import-review-drawer.tsx new file mode 100644 index 000000000..6a936b813 --- /dev/null +++ b/apps/wodsmith-start/src/components/organizer-import/import-review-drawer.tsx @@ -0,0 +1,419 @@ +import { useRouter } from "@tanstack/react-router" +import { useAgent } from "agents/react" +import { useEffect, useMemo, useRef, useState } from "react" +import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" +import { VOLUNTEER_ROLE_LABELS } from "@/db/schemas/volunteers" +import { useSession } from "@/utils/auth-client" +import type { + AgentState, + VolunteerProposal, +} from "@/lib/organizer-file-import/schemas" +import { isApplicableVolunteer } from "@/lib/organizer-file-import/validate" +import { + type ApplyImportResult, + applyOrganizerImportFn, + undoImportFn, +} from "@/server-fns/organizer-file-import-fns" +import type { PageIntent } from "./use-page-intent" + +interface ImportReviewDrawerProps { + importRunId: string + competition: { id: string; name: string; organizingTeamId: string } + intent: PageIntent + onClose: () => void +} + +const STATUS_LABEL: Record = { + idle: "Idle", + parsing: "Reading the file…", + thinking: "Drafting proposals…", + proposals_ready: "Ready for review", + error: "Something went wrong", +} + +export function ImportReviewDrawer({ + importRunId, + competition, + intent, + onClose, +}: ImportReviewDrawerProps) { + const router = useRouter() + const session = useSession() + const userId = session?.userId ?? "anonymous" + const agent = useAgent({ + agent: "organizer-file-import-agent", + // The runId is a valid name segment as-is (ULID is uppercase Crockford + // base32, accepted case-insensitively by the server.ts route regex). + name: `${importRunId}__${userId}`, + }) + + const status = agent.state?.status ?? "parsing" + const proposals = agent.state?.volunteerProposals ?? [] + const thinkingLog = agent.state?.thinkingLog ?? [] + const clarification = agent.state?.clarification ?? null + const parseWarnings = agent.state?.parseWarnings ?? [] + const summary = agent.state?.summary + const errorMessage = agent.state?.errorMessage + + const [excluded, setExcluded] = useState>(new Set()) + const [isApplying, setIsApplying] = useState(false) + const [refineText, setRefineText] = useState("") + const [receipt, setReceipt] = useState(null) + const startedRef = useRef(false) + + // One drag → one run: kick the agent off exactly once when the drawer mounts. + useEffect(() => { + if (startedRef.current) return + startedRef.current = true + agent.stub + .start({ + importRunId, + competitionId: competition.id, + routeKind: intent.routeKind, + eventId: intent.eventId, + }) + .catch((err: unknown) => { + toast.error( + err instanceof Error ? err.message : "Failed to start the import", + ) + }) + }, [agent.stub, importRunId, competition.id, intent.routeKind, intent.eventId]) + + const pending = useMemo( + () => proposals.filter((p) => p.status === "pending"), + [proposals], + ) + const applicable = useMemo( + () => pending.filter(isApplicableVolunteer), + [pending], + ) + const blocked = useMemo( + () => pending.filter((p) => !isApplicableVolunteer(p)), + [pending], + ) + const included = useMemo( + () => applicable.filter((p) => !excluded.has(p.proposalId)), + [applicable, excluded], + ) + + const isWorking = status === "parsing" || status === "thinking" + + function toggleExclude(proposalId: string) { + setExcluded((prev) => { + const next = new Set(prev) + if (next.has(proposalId)) next.delete(proposalId) + else next.add(proposalId) + return next + }) + } + + async function handleConfirm() { + if (included.length === 0) return + setIsApplying(true) + try { + const result = await applyOrganizerImportFn({ + data: { importRunId, volunteerProposals: included }, + }) + const appliedIds = result.results + .filter((r) => r.status === "applied") + .map((r) => r.proposalId) + if (appliedIds.length > 0) { + await agent.stub.markApplied({ proposalIds: appliedIds }).catch(() => {}) + } + await router.invalidate() + setReceipt(result) + toast.success( + `Imported ${result.applied} volunteer${result.applied === 1 ? "" : "s"}.`, + ) + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to apply the import", + ) + } finally { + setIsApplying(false) + } + } + + async function handleRefine() { + const instruction = refineText.trim() + if (!instruction || isWorking) return + setRefineText("") + try { + await agent.stub.refine({ instruction }) + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to refine") + } + } + + async function handleUndo() { + try { + const res = await undoImportFn({ data: { importRunId } }) + await router.invalidate() + toast.success( + `Undone — removed ${res.removed} invitation${res.removed === 1 ? "" : "s"}.`, + ) + onClose() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to undo") + } + } + + const latestActivity = thinkingLog[thinkingLog.length - 1]?.message + + return ( + { + if (!next) onClose() + }} + > + + + Import {intent.label} + + Drafted from your file for {competition.name}. Nothing is saved until + you confirm. + + + + {receipt ? ( + + ) : ( + <> +
+ {isWorking && ( + + )} + {STATUS_LABEL[status]} + {latestActivity && ( + + · {latestActivity} + + )} +
+ + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + + {clarification && ( +
+

{clarification.question}

+
+ )} + + {parseWarnings.length > 0 && ( +
    + {parseWarnings.slice(0, 5).map((w) => ( +
  • • {w}
  • + ))} +
+ )} + + {pending.length === 0 && !isWorking && !clarification && ( +

+ No people to import were found in this file. +

+ )} + + {applicable.map((proposal) => ( + toggleExclude(proposal.proposalId)} + /> + ))} + + {blocked.length > 0 && ( +
+

+ Won't import ({blocked.length}) +

+ {blocked.map((proposal) => ( + + ))} +
+ )} + + {summary && status === "proposals_ready" && ( +

{summary}

+ )} +
+
+ +
+
+