First production phase of the Pics Portal feature build. Establishes the subject as the load-bearing entity, ships the four Subjects-tab workflows from the PlicGo / Captura incumbent (grid, new, find duplicates, directory), and delivers the 25 standard SIS exports as async jobs with downloadable history.
Browse, filter, search, and edit subjects in a grid view.
Run duplicate detection across user-chosen fields and resolve the matches.
Download a grouped subject directory PDF.
Run any of 25 standard SIS exports as an async job and download the result.
End-to-end, no engineering hand-holding required, scoped to a single event.
Out of scope for Phase 1
Custom export format authoring (Phase 3).
Print layouts and rendering (Phase 2).
Customer-facing access and RBAC beyond the existing Pics-admin gate (Phase 4).
Bulk operations beyond the CSV import (no bulk edit, no bulk delete in v1).
Cross-event subject operations (e.g. roster reuse across events).
Image upload / replacement (Phase 2 — when we wire the image pipeline).
Personas in scope
Phase 1 is staffed by Pics admins and studio operators. Both are authenticated through the existing Pics admin login. Customer roles are out of scope here — see vision doc personas for the full set.
User stories
Subject management
As a studio operator, I can navigate from an event detail page to its subject roster.
As a studio operator, I can see all subjects in the event in a paginated card grid with filters (Teacher, Grade, Images present/missing), a sort dropdown, and a free-text search.
As a studio operator, I can add a single subject by filling in a form.
As a studio operator, I can edit a subject by clicking into its card.
As a studio operator, I can delete a subject with a confirmation step.
As a studio operator, I can import a CSV roster and see staged subjects appear in the grid.
Duplicates
As a studio operator, I can open Find Duplicates, pick the fields to match on (defaults: First Name, Last Name, Student ID), and see grouped match results.
As a studio operator, I can mark a match as a real duplicate (and merge / keep one) or as a false positive (and dismiss).
Directory
As a studio operator, I can request a Subject Directory PDF, pick a Group By field, and download the PDF when it's ready.
Exports
As a studio operator, I can pick from 25 standard SIS export formats, kick off an export job, and watch it complete.
As a studio operator, I can see a history of past exports with format, subject count, creator, timestamp, and status.
As a studio operator, I can redownload a completed export.
As a studio operator, I can cancel a queued export job and delete a completed export from the history.
Functional requirements
Subject model
A subject is a person being photographed at an event. Each subject belongs to exactly one event. The schema has a stable core plus an extensible custom-field surface.
image_url, image_status — nullable; filled by the Phase 2 image pipeline. In Phase 1 we read these from Pics when available and don't write them ourselves.
An additional custom_fields column (JSON or JSONB depending on storage substrate) carries the long-tail PlicGo schema: Sequence Number, Record Number, Yearbook Pic, Custom 5, Custom 9, Home Phone, Address, City, State, Zip, Library ID, Cafeteria ID, Prefix, Position, Period, Track, Home Room, Shoot #, Code, Proof #, Packages, Date of Birth. Each is a string. The portal's import / form / export pipelines treat custom_fields keys as first-class for purposes of column mapping in exports.
Schema location is an open question
Phase 1 PRD does not commit to where this table lives (portal Postgres vs. Pics-owned via new API endpoints vs. hybrid). The schema is described in storage-abstract terms; the subject-store ADR picks the substrate. See Pics API gap for the three postures.
Subjects grid
Per-event subject roster, accessed from the event detail page via a Subjects sub-tab (next to the existing event metadata + CSV-staging card).
Layout: responsive card grid (4 columns desktop, 2 tablet, 1 mobile). Mirrors the PlicGo grid: portrait + name title + three-row metadata table (Student ID / Teacher / Grade).
Filters above the grid: Teacher (dropdown of distinct values), Grade (dropdown), Images (Any / Present / Missing), Added Since Date.
Sort dropdown: Last Name First Name (default), First Name Last Name, Student ID, Recently Added.
Pagination via existing PaginationControls component. 24 cards per page (6 rows × 4 cols).
Counter copy: "N of M subjects" when filters narrow the set; otherwise just "M subjects". Matches the existing VenuesTable behavior.
Cards have an edit affordance (clicking opens the detail / edit page) and a delete (kebab menu) with a confirm dialog.
New Subject form
Route: /events/[eventId]/subjects/new.
Server action on submit; redirect to grid on success.
Two-column layout. Core fields top (name, IDs, teacher, grade, job type), custom fields below in a collapsed accordion.
Validation: at least one of first_name / last_name required; student_id must be unique within event when present; all custom fields free-text.
Server-side dedupe nudge: on submit, if exact match on First Name + Last Name + Student ID exists, show a warning panel with "Edit existing" / "Create anyway" branches before write.
Edit Subject
Route: /events/[eventId]/subjects/[subjectId].
Same field layout as the new form. Save / Cancel / Delete actions.
Show updated_at and updated_by at the bottom of the page.
Bulk import via CSV
Generalizes the existing event-level CSV-staging flow into a subject importer.
Trigger: "Import CSV" button on the subjects grid; opens a modal.
Step 1 — upload + parse: stream the CSV, infer columns, show a header-mapping table (CSV column → subject field / custom field). Pre-fill mappings by exact header match.
Step 2 — preview: first 20 rows rendered as a table, with row-level validation flags (missing required field, malformed value).
Step 3 — commit: server action inserts rows. On success, redirect to grid with a success toast and a filter pinned to "Added Since Date = today".
Limit: 5,000 rows per import in v1; larger imports go through an async job (same job substrate as exports) with progress UI.
Errors: per-row failure rows surfaced post-commit with a downloadable error CSV listing the offending rows + reasons.
Find Duplicates
Route: /events/[eventId]/subjects/duplicates.
UI: field-chip picker matching the PlicGo screenshot. Default fields: First Name, Last Name, Student ID. User can add any subject field (core or custom).
Matching: exact match on the user-selected field set, case-insensitive for names, whitespace-trimmed. Empty values do not match other empty values — a group requires at least one non-empty value in every selected field.
Results: groups of 2+ subjects rendered as collapsible match cards showing each subject's full record. Per-group actions: "Keep one + merge" (opens merge dialog) or "Mark as not duplicates" (dismisses for this matcher).
Merge dialog: shows field-by-field comparison; user picks a value per field; result writes back to one subject row; other subjects are soft-deleted with a merged_into_subject_id tombstone reference.
No fuzzy matching in v1. Open question below.
Download Subject Directory
Route: /events/[eventId]/subjects/directory.
Form: single required selector — Group By. Options: Teacher, Grade, Last Name (alphabetical sections), First Name, Job Type, School #.
Submit: enqueues a directory-render job and redirects to a status page that polls the job.
Output: a PDF with one section per group (title row + grouped subjects), each subject rendered with portrait + name + key fields. Page size: US Letter portrait in v1.
Same job substrate as exports.
Standard SIS exports — 25 formats
Each format is a deterministic transformation over the subject roster + the (Phase 2-populated) image set. In Phase 1 we ship the 25 formats listed in the PlicGo feature inventory:
Admin Plus, Alexandria, Aries by Eagle, Aspen VA SIS, Destiny by Follett, EduPoint Synergy, Fastlane 2000, Fast Track, Genesis, Infinite Campus, JMC, Lunchbox, Meals Plus, Mealtracker, Name Images with Student ID, Name Images with Student's Name, Nutri-Kids, PowerSchool, PSPA Yearbook (LEGACY), SASI XP, Skyward, SMS, SPOA School Software Universal Export, Star Base, WinSnap.
Each format is defined by a structured descriptor (column mapping × image naming × folder grouping × file packaging). The descriptors live in version-controlled code in Phase 1; Phase 3 lifts them into a database-backed authoring surface. Until then, we maintain them as TypeScript modules under lib/exports/formats/.
Per-format data:
Column mapping: subject field → output column. Some formats invent columns (e.g. zero-padded IDs, derived names).
Subject filter: e.g. "faculty only" for the Infinite Campus Forsyth Co GA format (which will move to custom in Phase 3, but is illustrative). All 25 standard formats are scoped to "all subjects in the event" in Phase 1.
Image naming rule: for formats that include images (Name Images variants), file naming follows a per-format template (e.g. {student_id}.jpg or {last_name}_{first_name}.jpg).
Packaging: ZIP archive with a CSV (or TXT) at the root and a flat or folder-sorted image directory; or CSV-only.
Format fidelity
Studios switching from PlicGo / Captura need byte-comparable exports for their downstream SIS systems. Each format gets a golden-file test fixture: a known subject set in, a known archive out. Test suite verifies fixture-out equality (modulo a sanitized timestamp).
Export jobs + download history
Route: /events/[eventId]/exports with three sub-tabs (matching PlicGo): Downloads (history), Standard Formats, Custom Formats (empty / "Coming in Phase 3" placeholder in v1).
Top bar: "Choose Export Format" dropdown + "Download" button. Selecting a format and clicking Download enqueues a job and switches to the Downloads tab.
History table: format name, subject count snapshot at enqueue time, created date, creator (admin), status (queued / running / completed / failed / cancelled), per-row download + delete buttons.
Auto-refresh: a checkbox in the header polls for status updates every 5s while there are non-terminal jobs.
Artifact lifetime: completed export artifacts live in object storage for 30 days, then are pruned (artifact removed, history row retained with a "re-run" affordance instead of "download").
Failure handling: failed jobs surface a per-job error message in the row; "retry" is available from the row.
Job substrate (shared across exports, directory PDFs, large imports)
The job substrate is an internal contract:
Enqueue from a server action with a typed payload.
Worker concurrency with per-studio limits (no single studio hogs the queue).
Status query by job id with { state, progress?: { current, total }, message?, artifact_url? }.
Cancellation while queued; running jobs cancel at the next checkpoint.
Persistence of job rows for the history view.
The substrate implementation (Supabase queue, Vercel background functions, dedicated service, etc.) is an ADR. The PRD only commits to the interface.
Grid must paginate cleanly for 25,000 subjects per event. Server-side filter / sort / search; no client-side over-fetch.
CSV import must handle 5,000 rows synchronously and up to 100,000 rows via async path.
Exports must handle 25,000-subject events end-to-end within the job timeout (target: under 10 minutes for the largest standard exports including images, subject to image pipeline work in Phase 2).
Accessibility
All forms keyboard-navigable. Field labels associated. Errors announced via aria-live.
Grid card focus order is reading order (left → right, top → bottom).
Color contrast meets WCAG AA both light and dark.
Observability
Job state transitions logged with job id, studio id, event id, format (where applicable), duration.
Export failure rate dashboard per-format.
Subject write rate counter for capacity planning.
Security
Every subject route enforces "current Pics admin's companies list includes this event's company". No raw subject IDs accessible without studio scope.
CSV import is run through a strict parser with size + column-count caps to avoid resource exhaustion.
Export artifact URLs are signed and scoped to the requesting admin's session.
Telemetry / success metrics
Time from event creation to first subject in the grid: median target 30s (for a 100-row roster).
Time from "enqueue export" to "download available" for a 1,000-subject CSV-only export: median target under 30s.
Duplicate-detection false-positive rate (reported via "Mark as not duplicates" clicks per match group): observed, no target in v1.
Export retry rate: under 2% in steady state.
UX references
Phase 1's surfaces map 1:1 to the PlicGo / Captura screenshots:
Subject store — portal Postgres (Supabase), Pics expansion, or hybrid? → dedicated ADR (next, immediately after this PRD review). Load-bearing for Phase 1 implementation.
Async job substrate — Supabase queue/cron, Vercel Background Functions, or dedicated queue service (BullMQ on Redis, Trigger.dev, etc.)? → ADR before Phase 1 implementation. Affects job rows, worker placement, cost model.
Image source for "Named Images" exports — Pics image endpoint (which doesn't exist yet), portal-cached copy, or skip in Phase 1 and defer all image-bearing exports to Phase 2? → resolve before scoping; current preference is "defer image-bearing exports to Phase 2" so Phase 1 ships data-only exports cleanly.
Duplicate-match algorithm — exact-match only (matches PlicGo and proposed default), or add a fuzzy / phonetic toggle? → resolve in this PRD; current proposal is exact-only with fuzzy deferred.
Custom-fields shape — JSONB blob vs. a structured custom-fields table with per-studio field definitions vs. PlicGo's fixed custom_5/custom_9 positional columns? → resolve in this PRD; current proposal is JSONB blob with per-studio field-definition metadata (gets us flexibility without per-studio schema migrations).
Subject identity vs. event scope — does the same real person across two events get one subject row or two? Affects de-duplication across the studio. → resolve in this PRD; current proposal is "one subject per event" (matches PlicGo), with cross-event identity deferred until we have a real use case for it.
CSV import header inference — strict exact-match on header names, or fuzzy match with a confirmation step? → resolve in this PRD; current proposal is exact-match with a confirmation step where the user maps unmatched columns manually.
Soft-delete TTL — how long do deleted / merged subjects persist before hard delete? → propose 90 days; revisit in security review.
Standard formats: which 25 ship — the inventory captured 25 names but format fidelity vs. PlicGo's actual byte output is unknown. Do we need access to PlicGo sample outputs as golden fixtures, or do we work from SIS documentation? → operational question; flag to product before impl planning.