From 045e958de5526b3eb45ffe938f60e9998fa9df00 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 10 Apr 2026 17:05:40 -0700 Subject: [PATCH 1/6] Fix security, migration, and UX issues from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Scope users.edit action by orgId to prevent cross-tenant user mutation - Prioritize username match in verifyLogin to avoid ambiguity when a username equals another user's email Migration fixes: - Preserve horseLeadersReq data (mapped to animalHandlersReq) during Event table redefinition — previously defaulted to 0 - Migrate existing Horse records into Animal table before dropping Horse - Migrate HorseAssignment, _EventToHorse, _horseLeader join data Volunteer role generalization: - Add role-labels utility that maps animalType to contextual role names (e.g. horses get "side walkers", dogs get "dog walkers") - Export getVolunteerTypes(animalType) for org-aware display names - Keep backward-compatible volunteerTypes const for existing consumers - Add VolunteerTypeEntry interface for type-safe event property access UX: - Add mobile-responsive sidebar with hamburger menu and overlay drawer - Restore theme toggle system/light/dark cycle (was light/dark only) - Replace hardcoded indigo-600 colors with theme tokens on landing page - Restore Opportunity Hack attribution in landing page footer - Fix tailwind.config sidebar color indentation Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/EventAgenda.tsx | 11 ++- app/components/sidebar.tsx | 80 ++++++++++++++-- app/data.ts | 76 +++++++++------ app/root.tsx | 2 +- app/routes/_marketing+/index.tsx | 25 ++++- .../admin+/_users+/users.edit.$userId.tsx | 10 +- app/routes/resources+/theme/index.tsx | 10 +- app/utils/auth.server.ts | 14 ++- app/utils/role-labels.ts | 94 +++++++++++++++++++ .../migration.sql | 57 ++++++----- tailwind.config.ts | 15 +-- 11 files changed, 308 insertions(+), 86 deletions(-) create mode 100644 app/utils/role-labels.ts diff --git a/app/components/EventAgenda.tsx b/app/components/EventAgenda.tsx index 175c4fa..568cb99 100644 --- a/app/components/EventAgenda.tsx +++ b/app/components/EventAgenda.tsx @@ -1,12 +1,13 @@ import { useUser } from '~/utils/user.ts' -import { volunteerTypes, type EventWithVolunteers } from '~/data.ts' - -type VolunteerTypes = typeof volunteerTypes -type VolunteerType = VolunteerTypes[number] +import { + volunteerTypes, + type EventWithVolunteers, + type VolunteerTypeEntry, +} from '~/data.ts' interface PositionStatusProps { event: EventWithVolunteers - volunteerType: VolunteerType + volunteerType: VolunteerTypeEntry } function PositionStatus({ volunteerType, event }: PositionStatusProps) { diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index e0492c4..a61155a 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -1,6 +1,6 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import { Form, Link, NavLink } from '@remix-run/react' -import { Cat } from 'lucide-react' +import { Cat, Menu, X } from 'lucide-react' import { Icon } from '~/components/ui/icon.tsx' import { ThemeSwitch } from '~/routes/resources+/theme/index.tsx' import { useUser } from '~/utils/user.ts' @@ -14,6 +14,7 @@ export function Sidebar({ const user = useUser() const formRef = useRef(null) const userIsAdmin = user?.roles.find(r => r.name === 'admin') + const [mobileOpen, setMobileOpen] = useState(false) const navLinkClass = ({ isActive }: { isActive: boolean }) => `flex items-center gap-3 rounded-md px-3 py-2 text-body-sm font-medium transition-colors ${ @@ -22,18 +23,30 @@ export function Sidebar({ : 'text-sidebar-foreground hover:bg-sidebar-border' }` - return ( - + + ) + + return ( + <> + {/* Mobile hamburger button */} + + + {/* Mobile overlay */} + {mobileOpen && ( +
setMobileOpen(false)} + /> + )} + + {/* Mobile sidebar drawer */} + + + {/* Desktop sidebar (always visible) */} + + ) } diff --git a/app/data.ts b/app/data.ts index 67f238d..9e876d1 100644 --- a/app/data.ts +++ b/app/data.ts @@ -1,4 +1,5 @@ import { Prisma } from '@prisma/client' +import { getRoleLabels, getRoleDescriptions } from '~/utils/role-labels.ts' export const siteName = 'The Barn Volunteer Portal' export const siteEmailAddress = 'hello@thebarnaz.com' @@ -6,37 +7,54 @@ export const siteEmailAddressWithName = siteName + ' ' export const siteBaseUrl = 'https://thebarnaz.com' -export const volunteerTypes = [ - { - displayName: 'cleaning crew', - field: 'cleaningCrew', - reqField: 'cleaningCrewReq', - description: - 'Cleaning crew volunteers help maintain the facility, check waterers, sweep common areas, and handle other miscellaneous cleaning tasks. No prior experience with animals is required.', - }, - { - displayName: 'side walkers', - field: 'sideWalkers', - reqField: 'sideWalkersReq', - description: - 'Side walkers walk alongside participants helping to support them during sessions. No prior experience with animals needed. Must be able to walk on uneven surfaces.', - }, - { - displayName: 'lesson assistants', - field: 'lessonAssistants', - reqField: 'lessonAssistantsReq', - description: - 'Lesson assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', - }, - { - displayName: 'animal handlers', - field: 'animalHandlers', - reqField: 'animalHandlersReq', - description: - 'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals, and must be able to walk on uneven surfaces.', - }, +/** Volunteer role field names — stable identifiers used in schema and forms */ +export const volunteerFields = [ + 'cleaningCrew', + 'sideWalkers', + 'lessonAssistants', + 'animalHandlers', ] as const +export type VolunteerField = (typeof volunteerFields)[number] + +export const volunteerReqFields: Record = { + cleaningCrew: 'cleaningCrewReq', + sideWalkers: 'sideWalkersReq', + lessonAssistants: 'lessonAssistantsReq', + animalHandlers: 'animalHandlersReq', +} + +/** + * Returns volunteer type metadata with display names and descriptions + * adapted to the organization's animal type. Falls back to generic labels + * when no animalType is provided. + */ +export function getVolunteerTypes(animalType?: string | null) { + const labels = getRoleLabels(animalType) + const descriptions = getRoleDescriptions(animalType) + + return volunteerFields.map(field => ({ + displayName: labels[field], + field, + reqField: volunteerReqFields[field], + description: descriptions[field], + })) +} + +/** Type-safe volunteer type entry for use in components that index into events */ +export interface VolunteerTypeEntry { + displayName: string + field: VolunteerField + reqField: string + description: string +} + +/** + * Default volunteer types using generic (non-org-specific) labels. + * Prefer getVolunteerTypes(animalType) when org context is available. + */ +export const volunteerTypes: VolunteerTypeEntry[] = getVolunteerTypes() + export interface UserData { id: string name: string | null diff --git a/app/root.tsx b/app/root.tsx index 2123cfa..3fceaf2 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -156,7 +156,7 @@ function App() { // Authenticated: sidebar layout
-
+
diff --git a/app/routes/_marketing+/index.tsx b/app/routes/_marketing+/index.tsx index 7cf76ed..6de558a 100644 --- a/app/routes/_marketing+/index.tsx +++ b/app/routes/_marketing+/index.tsx @@ -50,12 +50,12 @@ export default function Index() {
{/* Hero */}
- + Volunteer Scheduling for Nonprofits

Coordinate volunteers.{' '} - Simplify scheduling. + Simplify scheduling.

The Barn gives your organization a dedicated space to manage events, @@ -63,12 +63,12 @@ export default function Index() {

{user ? ( - ) : ( <> - )}
+ + {/* Attribution */} +
) } diff --git a/app/routes/admin+/_users+/users.edit.$userId.tsx b/app/routes/admin+/_users+/users.edit.$userId.tsx index bcd6e8a..cdae2c8 100644 --- a/app/routes/admin+/_users+/users.edit.$userId.tsx +++ b/app/routes/admin+/_users+/users.edit.$userId.tsx @@ -79,8 +79,16 @@ export const loader = async ({ request, params }: DataFunctionArgs) => { export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) - await requireOrgMember(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') + + // Verify target user belongs to this org before mutating + const targetUser = await prisma.user.findFirst({ + where: { id: params.userId, orgId }, + }) + if (!targetUser) { + throw new Response('not found', { status: 404 }) + } const formData = await request.formData() const submission = await parse(formData, { async: true, diff --git a/app/routes/resources+/theme/index.tsx b/app/routes/resources+/theme/index.tsx index 5ba1b83..a8ba00d 100644 --- a/app/routes/resources+/theme/index.tsx +++ b/app/routes/resources+/theme/index.tsx @@ -79,8 +79,9 @@ export function ThemeSwitch({ }, }) - const mode = userPreference ?? 'light' - const nextMode = mode === 'light' ? 'dark' : 'light' + const mode = userPreference ?? 'system' + const nextMode = + mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system' const modeLabel = { light: ( @@ -92,6 +93,11 @@ export function ThemeSwitch({ Dark ), + system: ( + + System + + ), } return ( diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 95360ce..fd7681f 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -180,12 +180,18 @@ export async function verifyLogin( usernameOrEmail: string, password: Password['hash'], ) { - const userWithPassword = await prisma.user.findFirst({ - where: { - OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }], - }, + // Prioritize exact username match to avoid ambiguity when a username + // happens to equal another user's email address + let userWithPassword = await prisma.user.findUnique({ + where: { username: usernameOrEmail }, select: { id: true, password: { select: { hash: true } } }, }) + if (!userWithPassword) { + userWithPassword = await prisma.user.findUnique({ + where: { email: usernameOrEmail }, + select: { id: true, password: { select: { hash: true } } }, + }) + } if (!userWithPassword || !userWithPassword.password) { return null diff --git a/app/utils/role-labels.ts b/app/utils/role-labels.ts new file mode 100644 index 0000000..6cd48d7 --- /dev/null +++ b/app/utils/role-labels.ts @@ -0,0 +1,94 @@ +/** + * Maps the internal volunteer slot field names to human-readable display names + * based on the organization's animal type. This allows orgs working with + * different animals to see contextually appropriate role names without + * changing the underlying schema. + */ + +interface RoleLabels { + cleaningCrew: string + sideWalkers: string + lessonAssistants: string + animalHandlers: string +} + +const defaultLabels: RoleLabels = { + cleaningCrew: 'cleaning crew', + sideWalkers: 'support volunteers', + lessonAssistants: 'session assistants', + animalHandlers: 'animal handlers', +} + +const labelsByAnimalType: Record> = { + horses: { + sideWalkers: 'side walkers', + lessonAssistants: 'lesson assistants', + animalHandlers: 'horse leaders', + }, + dogs: { + sideWalkers: 'dog walkers', + lessonAssistants: 'activity assistants', + animalHandlers: 'dog handlers', + }, + cats: { + sideWalkers: 'socialization helpers', + lessonAssistants: 'session assistants', + animalHandlers: 'cat handlers', + }, + wildlife: { + sideWalkers: 'habitat assistants', + lessonAssistants: 'program assistants', + animalHandlers: 'wildlife handlers', + }, +} + +export function getRoleLabels(animalType?: string | null): RoleLabels { + const overrides = animalType ? labelsByAnimalType[animalType] : undefined + return { ...defaultLabels, ...overrides } +} + +interface RoleDescription { + cleaningCrew: string + sideWalkers: string + lessonAssistants: string + animalHandlers: string +} + +const defaultDescriptions: RoleDescription = { + cleaningCrew: + 'Cleaning crew volunteers help maintain the facility, check waterers, sweep common areas, and handle other miscellaneous cleaning tasks. No prior experience with animals is required.', + sideWalkers: + 'Support volunteers assist participants during sessions. No prior experience with animals needed.', + lessonAssistants: + 'Session assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', + animalHandlers: + 'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals.', +} + +const descriptionsByAnimalType: Record> = { + horses: { + sideWalkers: + 'Side walkers walk alongside riders helping to support them during lessons. No prior experience with horses needed. Must be able to walk on uneven surfaces.', + lessonAssistants: + 'Lesson assistants should have 1+ years of experience with horses. They must be able to groom and tack horses, and to communicate effectively with both students and instructors.', + animalHandlers: + 'Horse leaders lead horses during lessons. Should have 1+ years of experience with horses, and must be able to walk on uneven surfaces.', + }, + dogs: { + sideWalkers: + 'Dog walkers take dogs on scheduled walks and help with socialization activities. Must be comfortable handling dogs of varying sizes.', + lessonAssistants: + 'Activity assistants help run training sessions and enrichment programs. Should have experience with dog behavior and basic commands.', + animalHandlers: + 'Dog handlers manage dogs during adoption events and therapy visits. Should have 1+ years of experience with dogs.', + }, +} + +export function getRoleDescriptions( + animalType?: string | null, +): RoleDescription { + const overrides = animalType + ? descriptionsByAnimalType[animalType] + : undefined + return { ...defaultDescriptions, ...overrides } +} diff --git a/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql b/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql index 49271fe..71e1c20 100644 --- a/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql +++ b/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql @@ -22,27 +22,7 @@ DROP INDEX "_horseLeader_B_index"; -- DropIndex DROP INDEX "_horseLeader_AB_unique"; --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "Horse"; -PRAGMA foreign_keys=on; - --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "HorseAssignment"; -PRAGMA foreign_keys=on; - --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "_EventToHorse"; -PRAGMA foreign_keys=on; - --- DropTable -PRAGMA foreign_keys=off; -DROP TABLE "_horseLeader"; -PRAGMA foreign_keys=on; - --- CreateTable +-- CreateTable (before dropping Horse so we can migrate data) CREATE TABLE "Animal" ( "id" TEXT NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, @@ -59,6 +39,10 @@ CREATE TABLE "Animal" ( CONSTRAINT "Animal_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image" ("fileId") ON DELETE SET NULL ON UPDATE CASCADE ); +-- Migrate existing Horse data into Animal +INSERT INTO "Animal" ("id", "name", "species", "notes", "status", "updatedAt", "cooldown", "cooldownStartDate", "cooldownEndDate", "orgId", "imageId") +SELECT "id", "name", 'horse', "notes", "status", "updatedAt", "cooldown", "cooldownStartDate", "cooldownEndDate", "orgId", "imageId" FROM "Horse"; + -- CreateTable CREATE TABLE "AnimalAssignment" ( "id" TEXT NOT NULL PRIMARY KEY, @@ -70,6 +54,10 @@ CREATE TABLE "AnimalAssignment" ( CONSTRAINT "AnimalAssignment_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- Migrate existing HorseAssignment data into AnimalAssignment +INSERT INTO "AnimalAssignment" ("id", "eventId", "userId", "animalId") +SELECT "id", "eventId", "userId", "horseId" FROM "HorseAssignment"; + -- CreateTable CREATE TABLE "_AnimalToEvent" ( "A" TEXT NOT NULL, @@ -78,6 +66,10 @@ CREATE TABLE "_AnimalToEvent" ( CONSTRAINT "_AnimalToEvent_B_fkey" FOREIGN KEY ("B") REFERENCES "Event" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- Migrate existing Horse-Event join data +INSERT INTO "_AnimalToEvent" ("A", "B") +SELECT "A", "B" FROM "_EventToHorse"; + -- CreateTable CREATE TABLE "_animalHandler" ( "A" TEXT NOT NULL, @@ -86,6 +78,27 @@ CREATE TABLE "_animalHandler" ( CONSTRAINT "_animalHandler_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE ); +-- Migrate existing horseLeader join data +INSERT INTO "_animalHandler" ("A", "B") +SELECT "A", "B" FROM "_horseLeader"; + +-- Now safe to drop old tables +PRAGMA foreign_keys=off; +DROP TABLE "Horse"; +PRAGMA foreign_keys=on; + +PRAGMA foreign_keys=off; +DROP TABLE "HorseAssignment"; +PRAGMA foreign_keys=on; + +PRAGMA foreign_keys=off; +DROP TABLE "_EventToHorse"; +PRAGMA foreign_keys=on; + +PRAGMA foreign_keys=off; +DROP TABLE "_horseLeader"; +PRAGMA foreign_keys=on; + -- RedefineTables PRAGMA foreign_keys=OFF; CREATE TABLE "new_Event" ( @@ -101,7 +114,7 @@ CREATE TABLE "new_Event" ( "isPrivate" BOOLEAN NOT NULL DEFAULT false, CONSTRAINT "Event_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization" ("id") ON DELETE SET NULL ON UPDATE CASCADE ); -INSERT INTO "new_Event" ("cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "start", "title") SELECT "cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "start", "title" FROM "Event"; +INSERT INTO "new_Event" ("cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "animalHandlersReq", "start", "title") SELECT "cleaningCrewReq", "end", "id", "isPrivate", "lessonAssistantsReq", "orgId", "sideWalkersReq", "horseLeadersReq", "start", "title" FROM "Event"; DROP TABLE "Event"; ALTER TABLE "new_Event" RENAME TO "Event"; CREATE UNIQUE INDEX "Event_id_key" ON "Event"("id"); diff --git a/tailwind.config.ts b/tailwind.config.ts index 118a4c7..94aa638 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -75,13 +75,14 @@ export default { foreground: 'hsl(var(--color-card-foreground))', }, sidebar: { - DEFAULT: 'hsl(var(--color-sidebar))', - foreground: 'hsl(var(--color-sidebar-foreground))', - border: 'hsl(var(--color-sidebar-border))', - active: 'hsl(var(--color-sidebar-active))', - 'active-foreground': 'hsl(var(--color-sidebar-active-foreground))', - }, - day: { + DEFAULT: 'hsl(var(--color-sidebar))', + foreground: 'hsl(var(--color-sidebar-foreground))', + border: 'hsl(var(--color-sidebar-border))', + active: 'hsl(var(--color-sidebar-active))', + 'active-foreground': + 'hsl(var(--color-sidebar-active-foreground))', + }, + day: { 100: 'hsl(var(--color-day-100))', 200: 'hsl(var(--color-day-200))', 300: 'hsl(var(--color-day-300))', From d7ceb0f168a25243190cb4753d17bba515130d07 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 10 Apr 2026 17:15:58 -0700 Subject: [PATCH 2/6] Fix TS errors: use typed accessors for volunteer event fields The VolunteerTypeEntry.field and reqField types are string unions that can't directly index into Prisma's EventGetPayload types. Add getVolunteers() and getVolunteerReq() typed helper functions and use them in EventAgenda, $eventId, and calendar/index to satisfy strict type checking. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/components/EventAgenda.tsx | 16 +++++++++------- app/data.ts | 23 ++++++++++++++++++++--- app/routes/calendar+/$eventId.tsx | 12 ++++++------ app/routes/calendar+/index.tsx | 12 ++++++------ 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/app/components/EventAgenda.tsx b/app/components/EventAgenda.tsx index 568cb99..d377f6d 100644 --- a/app/components/EventAgenda.tsx +++ b/app/components/EventAgenda.tsx @@ -1,6 +1,8 @@ import { useUser } from '~/utils/user.ts' import { volunteerTypes, + getVolunteers, + getVolunteerReq, type EventWithVolunteers, type VolunteerTypeEntry, } from '~/data.ts' @@ -13,23 +15,23 @@ interface PositionStatusProps { function PositionStatus({ volunteerType, event }: PositionStatusProps) { const user = useUser() - const positionFilled = - event[volunteerType.field].length >= event[volunteerType.reqField] + const volunteers = getVolunteers(event, volunteerType.field) + const required = getVolunteerReq(event, volunteerType.reqField) + const positionFilled = volunteers.length >= required const containerClass = `grid grid-cols-2 gap-4 ${ positionFilled ? 'text-muted-foreground' : '' }` - const userIsRegistered = event[volunteerType.field] - .map(user => user.id) + const userIsRegistered = (volunteers as { id: string }[]) + .map(u => u.id) .includes(user.id) const volunteerTypeClass = `capitalize ${ userIsRegistered ? 'before:content-["✅"] before:pr-1' : '' }` - const spotsLeft = - event[volunteerType.reqField] - event[volunteerType.field].length + const spotsLeft = required - volunteers.length - if (event[volunteerType.reqField] > 0) + if (required > 0) return (
{volunteerType.displayName}
diff --git a/app/data.ts b/app/data.ts index 9e876d1..0a48c7f 100644 --- a/app/data.ts +++ b/app/data.ts @@ -17,12 +17,15 @@ export const volunteerFields = [ export type VolunteerField = (typeof volunteerFields)[number] -export const volunteerReqFields: Record = { +export const volunteerReqFields = { cleaningCrew: 'cleaningCrewReq', sideWalkers: 'sideWalkersReq', lessonAssistants: 'lessonAssistantsReq', animalHandlers: 'animalHandlersReq', -} +} as const + +export type VolunteerReqField = + (typeof volunteerReqFields)[VolunteerField] /** * Returns volunteer type metadata with display names and descriptions @@ -45,7 +48,7 @@ export function getVolunteerTypes(animalType?: string | null) { export interface VolunteerTypeEntry { displayName: string field: VolunteerField - reqField: string + reqField: VolunteerReqField description: string } @@ -103,6 +106,20 @@ export interface CalEvent { sideWalkers: UserData[] } +/** Safely index into an event by volunteer field name */ +export function getVolunteers< + T extends Record, +>(event: T, field: VolunteerField): T[VolunteerField] { + return event[field] +} + +/** Safely index into an event by volunteer req field name */ +export function getVolunteerReq< + T extends Record, +>(event: T, reqField: VolunteerReqField): number { + return event[reqField] +} + const EventWithAllRelations = Prisma.validator()({ include: { animals: true, diff --git a/app/routes/calendar+/$eventId.tsx b/app/routes/calendar+/$eventId.tsx index 440c996..c4df68d 100644 --- a/app/routes/calendar+/$eventId.tsx +++ b/app/routes/calendar+/$eventId.tsx @@ -13,7 +13,7 @@ import type { CalEvent, EventWithAllRelations, } from '~/data.ts' -import { volunteerTypes } from '~/data.ts' +import { volunteerTypes, getVolunteers, getVolunteerReq } from '~/data.ts' import { clsx } from 'clsx' import { useFetcher, Outlet } from '@remix-run/react' import { z } from 'zod' @@ -239,9 +239,9 @@ export function VolunteerSection({ event, }: volunteerSectionProps) { const idx = volunteerTypeIdx - const vts = volunteerTypes - const volunteers = event[vts[idx].field] - const volunteersRequired = event[vts[idx].reqField] + const vt = volunteerTypes[idx] + const volunteers = getVolunteers(event, vt.field) as UserData[] + const volunteersRequired = getVolunteerReq(event, vt.reqField) let unfilled = volunteersRequired - volunteers.length let placeholders = [] @@ -252,13 +252,13 @@ export function VolunteerSection({ return (
-

{vts[idx].displayName}

+

{vt.displayName}

{volunteers.length} registered of {volunteersRequired} required

- {event[volunteerTypes[idx].field].map(user => { + {volunteers.map(user => { return })} {placeholders} diff --git a/app/routes/calendar+/index.tsx b/app/routes/calendar+/index.tsx index cdf2944..70f748f 100644 --- a/app/routes/calendar+/index.tsx +++ b/app/routes/calendar+/index.tsx @@ -6,6 +6,8 @@ import { Icon } from '~/components/ui/icon.tsx' import { volunteerTypes, + getVolunteers, + getVolunteerReq, type UserData, type AnimalData, type EventWithVolunteers, @@ -517,13 +519,11 @@ function RegistrationDialogue({ selectedEventId, events }: RegistrationProps) { >
    {volunteerTypes.map(volunteerType => { - const spotsLeft = - calEvent[`${volunteerType.field}Req`] - - calEvent[volunteerType.field].length + const volunteers = getVolunteers(calEvent, volunteerType.field) + const required = getVolunteerReq(calEvent, volunteerType.reqField) + const spotsLeft = required - volunteers.length - const isFull = - calEvent[volunteerType.reqField] <= - calEvent[volunteerType.field].length + const isFull = required <= volunteers.length let hasPermissions = true if (volunteerType.field == 'lessonAssistants') { From 13f05f16c1a81b8a1c37d56ad293fbb3a023d81c Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 10 Apr 2026 17:19:18 -0700 Subject: [PATCH 3/6] Bump deprecated GitHub Actions from v3 to v4 actions/checkout, actions/setup-node, actions/cache, and actions/upload-artifact v3 are all deprecated and causing CI failures. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4c96f20..54fe5b4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -38,10 +38,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -56,10 +56,10 @@ jobs: runs-on: ubuntu-latest steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -78,13 +78,13 @@ jobs: timeout-minutes: 60 steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 🏄 Copy test env vars run: cp .env.example .env - name: ⎔ Setup node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -99,7 +99,7 @@ jobs: - name: 🏦 Cache Database id: db-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: prisma/data.db key: @@ -114,7 +114,7 @@ jobs: run: npx playwright test - name: 📊 Upload report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: playwright-report @@ -132,7 +132,7 @@ jobs: steps: - name: ⬇️ Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 👀 Read app name uses: SebRollen/toml-action@v1.0.2 From e635758585096c4ed295835d9ac075a9899e5654 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 10 Apr 2026 17:21:51 -0700 Subject: [PATCH 4/6] Use horse-specific defaults for backward compatibility The default volunteerTypes labels and descriptions must match what the existing equestrian customer sees today. Changed defaults from generic terms ("support volunteers", "session assistants") back to horse-specific terms ("side walkers", "lesson assistants"). Non-horse orgs get their own labels via getVolunteerTypes(animalType) overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/data.ts | 5 +++-- app/utils/role-labels.ts | 31 +++++++++++++++++-------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/data.ts b/app/data.ts index 0a48c7f..3a4f220 100644 --- a/app/data.ts +++ b/app/data.ts @@ -53,8 +53,9 @@ export interface VolunteerTypeEntry { } /** - * Default volunteer types using generic (non-org-specific) labels. - * Prefer getVolunteerTypes(animalType) when org context is available. + * Default volunteer types using horse-specific labels for backward + * compatibility with the existing equestrian customer. Use + * getVolunteerTypes(animalType) when org context is available. */ export const volunteerTypes: VolunteerTypeEntry[] = getVolunteerTypes() diff --git a/app/utils/role-labels.ts b/app/utils/role-labels.ts index 6cd48d7..41a38b7 100644 --- a/app/utils/role-labels.ts +++ b/app/utils/role-labels.ts @@ -12,18 +12,21 @@ interface RoleLabels { animalHandlers: string } +/** + * Default labels use horse-specific terms to maintain backward compatibility + * with the existing equestrian customer. New orgs with a different animalType + * will get their own contextual labels via the overrides below. + */ const defaultLabels: RoleLabels = { cleaningCrew: 'cleaning crew', - sideWalkers: 'support volunteers', - lessonAssistants: 'session assistants', + sideWalkers: 'side walkers', + lessonAssistants: 'lesson assistants', animalHandlers: 'animal handlers', } const labelsByAnimalType: Record> = { horses: { - sideWalkers: 'side walkers', - lessonAssistants: 'lesson assistants', - animalHandlers: 'horse leaders', + // defaults already use horse-specific terms; only override if needed }, dogs: { sideWalkers: 'dog walkers', @@ -54,25 +57,25 @@ interface RoleDescription { animalHandlers: string } +/** + * Default descriptions use horse-specific language to match the existing + * equestrian customer's experience. Overrides below provide contextual + * descriptions for other animal types. + */ const defaultDescriptions: RoleDescription = { cleaningCrew: 'Cleaning crew volunteers help maintain the facility, check waterers, sweep common areas, and handle other miscellaneous cleaning tasks. No prior experience with animals is required.', sideWalkers: - 'Support volunteers assist participants during sessions. No prior experience with animals needed.', + 'Side walkers walk alongside participants helping to support them during sessions. No prior experience with animals needed. Must be able to walk on uneven surfaces.', lessonAssistants: - 'Session assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', + 'Lesson assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', animalHandlers: - 'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals.', + 'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals, and must be able to walk on uneven surfaces.', } const descriptionsByAnimalType: Record> = { horses: { - sideWalkers: - 'Side walkers walk alongside riders helping to support them during lessons. No prior experience with horses needed. Must be able to walk on uneven surfaces.', - lessonAssistants: - 'Lesson assistants should have 1+ years of experience with horses. They must be able to groom and tack horses, and to communicate effectively with both students and instructors.', - animalHandlers: - 'Horse leaders lead horses during lessons. Should have 1+ years of experience with horses, and must be able to walk on uneven surfaces.', + // defaults already use horse-appropriate language }, dogs: { sideWalkers: From 5c95cffa46f67893c8329681d54e0e3b827a6229 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 10 Apr 2026 17:28:59 -0700 Subject: [PATCH 5/6] Pin Playwright CI job to ubuntu-22.04 Playwright 1.35 can't install on Ubuntu 24.04 (Noble) because the libasound2 package was renamed to libasound2t64. Pin to ubuntu-22.04 until Playwright is upgraded. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 54fe5b4..67f10de 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -74,7 +74,7 @@ jobs: playwright: name: 🎭 Playwright - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 timeout-minutes: 60 steps: - name: ⬇️ Checkout repo From 4acea7db3c9182b1e18d0c5e2d68c4a60a9a09a8 Mon Sep 17 00:00:00 2001 From: Greg V Date: Fri, 10 Apr 2026 17:38:40 -0700 Subject: [PATCH 6/6] Fix E2E tests for multi-tenant UI changes - insertNewUser/insertNewAdmin now assign users to a default org so requireOrgMember doesn't redirect to /org-setup during tests - Update onboarding test: new users without an org land on /org-setup after signup, not the homepage - Update login test: check for user name as text (sidebar) instead of dropdown link - Update admin tests: admin login redirects to /admin, sidebar has direct nav links instead of dropdown menu items - Update users page heading assertion: renamed from "Users" to "Volunteers" in the multi-tenant PR Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/e2e/admin.test.ts | 20 ++++++++++++-------- tests/e2e/onboarding.test.ts | 22 +++++++--------------- tests/playwright-utils.ts | 18 ++++++++++++++++++ 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/tests/e2e/admin.test.ts b/tests/e2e/admin.test.ts index ace6923..f3566f1 100644 --- a/tests/e2e/admin.test.ts +++ b/tests/e2e/admin.test.ts @@ -12,9 +12,14 @@ test('Admin can log in and see admin panel', async ({ page }) => { .fill(adminUser.username) await page.getByLabel(/^password$/i).fill(password) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - await expect(page.getByRole('link', { name: /admin/i })).toBeVisible() + // Admin login redirects to /admin which redirects to /admin/users + await expect(page).toHaveURL(/\/admin/) + + // Sidebar shows admin nav links + await expect(page.getByRole('link', { name: /users/i })).toBeVisible() + await expect(page.getByRole('link', { name: /animals/i })).toBeVisible() + await expect(page.getByRole('link', { name: /email/i })).toBeVisible() }) test('Admin can edit users with proper validation for height and feet', async ({ @@ -29,14 +34,13 @@ test('Admin can edit users with proper validation for height and feet', async ({ .fill(adminUser.username) await page.getByLabel(/^password$/i).fill(password) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - await expect(page.getByRole('link', { name: /admin/i })).toBeVisible() + // Admin login redirects to /admin/users + await expect(page).toHaveURL(/\/admin/) - // Navigate to users page - await page.getByRole('link', { name: /admin/i }).click() - await page.getByRole('menuitem', { name: 'Users' }).click() - await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible() + // Navigate to users page via sidebar + await page.getByRole('link', { name: /users/i }).click() + await expect(page.getByRole('heading', { name: /volunteers/i })).toBeVisible() // Open Edit user page await page.getByText('open menu').first().click() diff --git a/tests/e2e/onboarding.test.ts b/tests/e2e/onboarding.test.ts index 55c0de2..ed32025 100644 --- a/tests/e2e/onboarding.test.ts +++ b/tests/e2e/onboarding.test.ts @@ -92,16 +92,8 @@ test('onboarding with link', async ({ page }) => { await page.getByRole('button', { name: /Create an account/i }).click() - await expect(page).toHaveURL(`/`) - - await page.getByRole('link', { name: onboardingData.name }).click() - await page.getByRole('menuitem', { name: /profile/i }).click() - - await expect(page).toHaveURL(`/users/${onboardingData.username}`) - - await page.getByRole('link', { name: onboardingData.name }).click() - await page.getByRole('menuitem', { name: /logout/i }).click() - await expect(page).toHaveURL(`/`) + // After onboarding, user has no org — lands on org-setup + await expect(page).toHaveURL(`/org-setup`) // have to do this here because we didn't use insertNewUser (because we're testing user create) await deleteUserByUsername(onboardingData.username) @@ -162,9 +154,10 @@ test('login as existing user', async ({ page }) => { await page.getByRole('textbox', { name: /username/i }).fill(user.username) await page.getByLabel(/^password$/i).fill(password) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - await expect(page.getByRole('link', { name: user.name })).toBeVisible() + // After login, user lands on calendar (or admin redirect) + // Sidebar shows the user's name + await expect(page.getByText(user.name)).toBeVisible() }) test('reset password with a link', async ({ page }) => { @@ -215,9 +208,8 @@ test('reset password with a link', async ({ page }) => { await page.getByLabel(/^password$/i).fill(newPassword) await page.getByRole('button', { name: /log in/i }).click() - await expect(page).toHaveURL(`/`) - - await expect(page.getByRole('link', { name: user.name })).toBeVisible() + // Sidebar shows the user's name after successful login + await expect(page.getByText(user.name)).toBeVisible() }) test('reset password with a short code', async ({ page }) => { diff --git a/tests/playwright-utils.ts b/tests/playwright-utils.ts index 9e4ce47..ad3de8b 100644 --- a/tests/playwright-utils.ts +++ b/tests/playwright-utils.ts @@ -13,11 +13,27 @@ export function deleteUserByUsername(username: string) { return prisma.user.delete({ where: { username } }) } +export async function getOrCreateDefaultOrg() { + let org = await prisma.organization.findFirst() + if (!org) { + org = await prisma.organization.create({ + data: { + name: 'Test Org', + slug: 'test-org', + animalType: 'horses', + }, + }) + } + return org +} + export async function insertNewUser({ password }: { password?: string } = {}) { const userData = createUser() + const org = await getOrCreateDefaultOrg() const user = await prisma.user.create({ data: { ...userData, + org: { connect: { id: org.id } }, password: { create: { hash: await getPasswordHash(password || userData.username), @@ -51,9 +67,11 @@ export async function insertNewAdmin({ password }: { password?: string } = {}) { }) } + const org = await getOrCreateDefaultOrg() const user = await prisma.user.create({ data: { ...userData, + org: { connect: { id: org.id } }, password: { create: { hash: await getPasswordHash(password || userData.username),