diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 4c96f20..67f10de 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
@@ -74,17 +74,17 @@ jobs:
playwright:
name: 🎭 Playwright
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
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
diff --git a/app/components/EventAgenda.tsx b/app/components/EventAgenda.tsx
index 175c4fa..d377f6d 100644
--- a/app/components/EventAgenda.tsx
+++ b/app/components/EventAgenda.tsx
@@ -1,34 +1,37 @@
import { useUser } from '~/utils/user.ts'
-import { volunteerTypes, type EventWithVolunteers } from '~/data.ts'
-
-type VolunteerTypes = typeof volunteerTypes
-type VolunteerType = VolunteerTypes[number]
+import {
+ volunteerTypes,
+ getVolunteers,
+ getVolunteerReq,
+ type EventWithVolunteers,
+ type VolunteerTypeEntry,
+} from '~/data.ts'
interface PositionStatusProps {
event: EventWithVolunteers
- volunteerType: VolunteerType
+ volunteerType: VolunteerTypeEntry
}
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/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..3a4f220 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,58 @@ 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 = {
+ 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
+ * 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: VolunteerReqField
+ description: string
+}
+
+/**
+ * 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()
+
export interface UserData {
id: string
name: string | null
@@ -85,6 +107,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/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 ? (
-
)
}
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/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') {
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..41a38b7
--- /dev/null
+++ b/app/utils/role-labels.ts
@@ -0,0 +1,97 @@
+/**
+ * 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
+}
+
+/**
+ * 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: 'side walkers',
+ lessonAssistants: 'lesson assistants',
+ animalHandlers: 'animal handlers',
+}
+
+const labelsByAnimalType: Record> = {
+ horses: {
+ // defaults already use horse-specific terms; only override if needed
+ },
+ 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
+}
+
+/**
+ * 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:
+ '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:
+ '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, and must be able to walk on uneven surfaces.',
+}
+
+const descriptionsByAnimalType: Record> = {
+ horses: {
+ // defaults already use horse-appropriate language
+ },
+ 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))',
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),