From aba25d538418a4c00800d5e952e56a3a32222a46 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:37:46 -0700 Subject: [PATCH 01/26] feat: add Organization model to Prisma schema --- .../migration.sql | 86 +++++++++++++++++++ prisma/schema.prisma | 28 ++++++ 2 files changed, 114 insertions(+) create mode 100644 prisma/migrations/20260313023719_add_organization_model/migration.sql diff --git a/prisma/migrations/20260313023719_add_organization_model/migration.sql b/prisma/migrations/20260313023719_add_organization_model/migration.sql new file mode 100644 index 0000000..f83ada2 --- /dev/null +++ b/prisma/migrations/20260313023719_add_organization_model/migration.sql @@ -0,0 +1,86 @@ +-- CreateTable +CREATE TABLE "Organization" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "animalType" TEXT NOT NULL DEFAULT 'mixed', + "description" TEXT, + "logoUrl" TEXT, + "website" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "username" TEXT NOT NULL, + "name" TEXT, + "phone" TEXT, + "mailingList" BOOLEAN NOT NULL DEFAULT true, + "orgId" TEXT, + "birthdate" DATETIME, + "height" INTEGER, + "yearsOfExperience" INTEGER, + "notes" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + "imageId" TEXT, + "lastLogin" DATETIME DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "User_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "User_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image" ("fileId") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("birthdate", "createdAt", "email", "height", "id", "imageId", "lastLogin", "mailingList", "name", "notes", "phone", "updatedAt", "username", "yearsOfExperience") SELECT "birthdate", "createdAt", "email", "height", "id", "imageId", "lastLogin", "mailingList", "name", "notes", "phone", "updatedAt", "username", "yearsOfExperience" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); +CREATE UNIQUE INDEX "User_imageId_key" ON "User"("imageId"); +CREATE TABLE "new_Event" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "start" DATETIME NOT NULL, + "end" DATETIME NOT NULL, + "orgId" TEXT, + "cleaningCrewReq" INTEGER NOT NULL DEFAULT 0, + "lessonAssistantsReq" INTEGER NOT NULL DEFAULT 0, + "sideWalkersReq" INTEGER NOT NULL DEFAULT 0, + "horseLeadersReq" INTEGER NOT NULL DEFAULT 0, + "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", "horseLeadersReq", "id", "isPrivate", "lessonAssistantsReq", "sideWalkersReq", "start", "title") SELECT "cleaningCrewReq", "end", "horseLeadersReq", "id", "isPrivate", "lessonAssistantsReq", "sideWalkersReq", "start", "title" FROM "Event"; +DROP TABLE "Event"; +ALTER TABLE "new_Event" RENAME TO "Event"; +CREATE UNIQUE INDEX "Event_id_key" ON "Event"("id"); +CREATE TABLE "new_Horse" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "notes" TEXT, + "status" TEXT, + "updatedAt" DATETIME NOT NULL, + "cooldown" BOOLEAN NOT NULL DEFAULT false, + "cooldownStartDate" DATETIME, + "cooldownEndDate" DATETIME, + "orgId" TEXT, + "imageId" TEXT, + CONSTRAINT "Horse_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Horse_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image" ("fileId") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Horse" ("cooldown", "cooldownEndDate", "cooldownStartDate", "id", "imageId", "name", "notes", "status", "updatedAt") SELECT "cooldown", "cooldownEndDate", "cooldownStartDate", "id", "imageId", "name", "notes", "status", "updatedAt" FROM "Horse"; +DROP TABLE "Horse"; +ALTER TABLE "new_Horse" RENAME TO "Horse"; +CREATE UNIQUE INDEX "Horse_id_key" ON "Horse"("id"); +CREATE UNIQUE INDEX "Horse_imageId_key" ON "Horse"("imageId"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_id_key" ON "Organization"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b1db976..f2c9d52 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,25 @@ generator client { provider = "prisma-client-js" } +model Organization { + id String @id @unique @default(cuid()) + name String + slug String @unique + animalType String @default("mixed") + description String? + logoUrl String? + website String? + isActive Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + horses Horse[] + events Event[] +} + + model File { id String @id @unique @default(cuid()) blob Bytes @@ -59,6 +78,9 @@ model User { phone String? mailingList Boolean @default(true) + org Organization? @relation(fields: [orgId], references: [id]) + orgId String? + birthdate DateTime? height Int? yearsOfExperience Int? @@ -138,6 +160,9 @@ model Horse { cooldownStartDate DateTime? cooldownEndDate DateTime? + org Organization? @relation(fields: [orgId], references: [id]) + orgId String? + image Image? @relation(fields: [imageId], references: [fileId]) imageId String? @unique @@ -152,6 +177,9 @@ model Event { start DateTime end DateTime + org Organization? @relation(fields: [orgId], references: [id]) + orgId String? + instructors User[] @relation("instructor") cleaningCrew User[] @relation("cleaningCrew") From 2393e1d82ec456fddb1c5cc4f25f1922bfd9576a Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:47:09 -0700 Subject: [PATCH 02/26] feat: add OrgAdmin role and org-scoped permissions --- app/utils/auth.server.ts | 16 ++++++++++++++++ app/utils/permissions.server.ts | 8 ++++++++ prisma/seed.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index 1e24240..d016f34 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -49,6 +49,22 @@ authenticator.use( FormStrategy.name, ) +export async function requireOrgMember( + request: Request, + { redirectTo }: { redirectTo?: string | null } = {}, +) { + const userId = await requireUserId(request, { redirectTo }) + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { orgId: true }, + }) + if (!user?.orgId) { + // User exists but has no org โ€” send them to pick/create one + throw redirect('/org-setup') + } + return { userId, orgId: user.orgId } +} + export async function requireUserId( request: Request, { redirectTo }: { redirectTo?: string | null } = {}, diff --git a/app/utils/permissions.server.ts b/app/utils/permissions.server.ts index 0d4d8b4..c3d368c 100644 --- a/app/utils/permissions.server.ts +++ b/app/utils/permissions.server.ts @@ -34,3 +34,11 @@ export async function userHasPermissions(name: string, request: Request) { export async function userHasAdminPermissions(request: Request) { return userHasPermissions('admin', request) } + +export async function requireSuperAdmin(request: Request) { + return requireUserWithPermission('superAdmin', request) +} + +export async function userIsSuperAdmin(request: Request) { + return userHasPermissions('superAdmin', request) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index e8ef0fa..ec39efb 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -19,6 +19,19 @@ async function seed() { deleteAllData() console.timeEnd('๐Ÿงน Cleaned up the database...') + console.time(`๐Ÿ  Created default organization...`) + const defaultOrg = await prisma.organization.create({ + data: { + name: 'Tumbling T Ranch', + slug: 'tumbling-t-ranch', + animalType: 'horses', + description: 'Equestrian therapy nonprofit for individuals with disabilities', + website: 'https://www.thebarnaz.com', + isActive: true, + }, + }) + console.timeEnd(`๐Ÿ  Created default organization...`) + console.time(`๐Ÿ‘‘ Created admin role/permission...`) const adminRole = await prisma.role.create({ data: { @@ -30,6 +43,17 @@ async function seed() { }) console.timeEnd(`๐Ÿ‘‘ Created admin role/permission...`) + console.time(`๐ŸŒ Created superAdmin role/permission...`) + const superAdminRole = await prisma.role.create({ + data: { + name: 'superAdmin', + permissions: { + create: { name: 'superAdmin' }, + }, + }, + }) + console.timeEnd(`๐ŸŒ Created superAdmin role/permission...`) + console.time(`Created lesson assistant role/permission...`) const lessonAssistantRole = await prisma.role.create({ data: { @@ -71,6 +95,7 @@ async function seed() { const user = await prisma.user.create({ data: { ...userData, + orgId: defaultOrg.id, password: { create: createPassword(userData.username), }, @@ -101,6 +126,7 @@ async function seed() { email: 'kody@kcd.dev', username: 'kody', name: 'Kody', + orgId: defaultOrg.id, roles: { connect: { id: adminRole.id } }, image: { create: { @@ -133,6 +159,7 @@ async function seed() { email: 'bob@not.admin', username: 'bob', name: 'Bob', + orgId: defaultOrg.id, image: { create: { contentType: 'image/png', @@ -163,6 +190,7 @@ async function seed() { email: 'isabelle@is.instructor', username: 'isabelle', name: 'Isabelle', + orgId: defaultOrg.id, roles: { connect: { id: instructorRole.id } }, image: { create: { @@ -195,6 +223,7 @@ async function seed() { const horse = await prisma.horse.create({ data: { ...horseData, + orgId: defaultOrg.id, image: { create: { contentType: 'image/jpeg', @@ -230,6 +259,7 @@ async function seed() { const event = await prisma.event.create({ data: { ...eventData, + orgId: defaultOrg.id, }, }) return event From fd69b28ed0e66b01431d0a96c42160992debefc1 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 19:59:18 -0700 Subject: [PATCH 03/26] feat: scope all Prisma queries by orgId --- .../admin+/_horses+/horses.delete.$horseId.tsx | 7 +++++-- .../admin+/_horses+/horses.edit.$horseId.tsx | 10 +++++++++- app/routes/admin+/_horses+/horses.tsx | 6 +++++- app/routes/admin+/_users+/users.tsx | 4 +++- app/routes/calendar+/$eventId.tsx | 5 ++++- app/routes/calendar+/index.tsx | 12 +++++++----- app/routes/resources+/event-register.tsx | 18 +++++++++++++++--- prisma/seed.ts | 12 ++++++------ 8 files changed, 54 insertions(+), 20 deletions(-) diff --git a/app/routes/admin+/_horses+/horses.delete.$horseId.tsx b/app/routes/admin+/_horses+/horses.delete.$horseId.tsx index a3e9828..25dcf4a 100644 --- a/app/routes/admin+/_horses+/horses.delete.$horseId.tsx +++ b/app/routes/admin+/_horses+/horses.delete.$horseId.tsx @@ -20,6 +20,7 @@ import { } from '@remix-run/react' import { json, type DataFunctionArgs } from '@remix-run/node' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' import invariant from 'tiny-invariant' import { conform, useForm } from '@conform-to/react' @@ -31,8 +32,9 @@ import { z } from 'zod' export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.horseId, 'Missing horse id') - const horse = await prisma.horse.findUnique({ where: { id: params.horseId } }) + const horse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) if (!horse) { throw new Response('not found', { status: 404 }) } @@ -50,9 +52,10 @@ export const deleteHorseFormSchema = z.object({ export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.horseId, 'Missing horse id') const formData = await request.formData() - const horse = await prisma.horse.findUnique({ where: { id: params.horseId } }) + const horse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) if (!horse) { throw new Response('not found', { status: 404 }) } diff --git a/app/routes/admin+/_horses+/horses.edit.$horseId.tsx b/app/routes/admin+/_horses+/horses.edit.$horseId.tsx index 38e58d7..d4324ac 100644 --- a/app/routes/admin+/_horses+/horses.edit.$horseId.tsx +++ b/app/routes/admin+/_horses+/horses.edit.$horseId.tsx @@ -28,6 +28,7 @@ import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert.tsx' import { AlertTriangle } from 'lucide-react' import { json, type DataFunctionArgs } from '@remix-run/node' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' import invariant from 'tiny-invariant' import { conform, useForm } from '@conform-to/react' @@ -39,8 +40,9 @@ import { format, add } from 'date-fns' export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.horseId, 'Missing horse id') - const horse = await prisma.horse.findUnique({ where: { id: params.horseId } }) + const horse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) if (!horse) { throw new Response('not found', { status: 404 }) } @@ -49,6 +51,7 @@ export const loader = async ({ request, params }: DataFunctionArgs) => { export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.horseId, 'Missing horse id') const formData = await request.formData() const submission = await parse(formData, { @@ -79,6 +82,11 @@ export async function action({ request, params }: DataFunctionArgs) { cooldownEndDate, } = submission.value + // Verify the horse belongs to the admin's org before updating + const existingHorse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) + if (!existingHorse) { + throw new Response('not found', { status: 404 }) + } const updatedHorse = await prisma.horse.update({ where: { id: params.horseId }, data: { diff --git a/app/routes/admin+/_horses+/horses.tsx b/app/routes/admin+/_horses+/horses.tsx index 781c0e9..cdfa1b2 100644 --- a/app/routes/admin+/_horses+/horses.tsx +++ b/app/routes/admin+/_horses+/horses.tsx @@ -10,6 +10,7 @@ import { } from '~/remix.ts' import { prisma } from '~/utils/db.server.ts' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { DataTable } from '~/components/ui/data_table.tsx' import { z } from 'zod' import { @@ -94,7 +95,8 @@ export const horseFormSchema = z export const loader = async ({ request }: DataFunctionArgs) => { await requireAdmin(request) - return json(await prisma.horse.findMany()) + const { orgId } = await requireOrgMember(request) + return json(await prisma.horse.findMany({ where: { orgId } })) } export default function Horses() { @@ -115,6 +117,7 @@ export default function Horses() { export const action = async ({ request }: ActionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) const formData = await request.formData() const submission = parse(formData, { schema: horseFormSchema }) if (!submission.value) { @@ -126,6 +129,7 @@ export const action = async ({ request }: ActionArgs) => { name: submission.value.name, notes: submission.value.notes, status: submission.value.status, + orgId, }, }) diff --git a/app/routes/admin+/_users+/users.tsx b/app/routes/admin+/_users+/users.tsx index 336b990..9a1b16d 100644 --- a/app/routes/admin+/_users+/users.tsx +++ b/app/routes/admin+/_users+/users.tsx @@ -1,6 +1,7 @@ import { type LoaderArgs, json, useLoaderData, Outlet, Link } from '~/remix.ts' import { prisma } from '~/utils/db.server.ts' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { DataTable } from '~/components/ui/data_table.tsx' import { type ColumnDef } from '@tanstack/react-table' @@ -21,7 +22,8 @@ import { SetSignupPasswordForm } from '~/routes/resources+/signup_password.tsx' export const loader = async ({ request }: LoaderArgs) => { await requireAdmin(request) - return json(await prisma.user.findMany({ include: { roles: true } })) + const { orgId } = await requireOrgMember(request) + return json(await prisma.user.findMany({ where: { orgId }, include: { roles: true } })) } export default function Users() { diff --git a/app/routes/calendar+/$eventId.tsx b/app/routes/calendar+/$eventId.tsx index 7a4a54f..47f6d74 100644 --- a/app/routes/calendar+/$eventId.tsx +++ b/app/routes/calendar+/$eventId.tsx @@ -19,6 +19,7 @@ import { useFetcher, Outlet } from '@remix-run/react' import { z } from 'zod' import { parse } from '@conform-to/zod' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { formatPhone } from '~/utils/phone-format.ts' import invariant from 'tiny-invariant' import { @@ -31,10 +32,12 @@ import type { HorseAssignment } from '@prisma/client' export async function loader({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) const id = params.eventId - const event = await prisma.event.findUnique({ + const event = await prisma.event.findFirst({ where: { id, + orgId, }, include: { instructors: true, diff --git a/app/routes/calendar+/index.tsx b/app/routes/calendar+/index.tsx index b6b57e0..e40a798 100644 --- a/app/routes/calendar+/index.tsx +++ b/app/routes/calendar+/index.tsx @@ -18,7 +18,7 @@ import { import { useMemo, useState } from 'react' import { prisma } from '~/utils/db.server.ts' -import { requireUserId } from '~/utils/auth.server.ts' +import { requireUserId, requireOrgMember } from '~/utils/auth.server.ts' import { useUser } from '~/utils/user.ts' import { Button } from '~/components/ui/button.tsx' import { @@ -86,13 +86,13 @@ const localizer = dateFnsLocalizer({ }) export const loader = async ({ request }: LoaderArgs) => { - await requireUserId(request) + const { orgId } = await requireOrgMember(request) const isAdmin = await userHasAdminPermissions(request) const instructors = await prisma.user.findMany({ - where: { roles: { some: { name: 'instructor' } } }, + where: { orgId, roles: { some: { name: 'instructor' } } }, }) - let eventsWhere: { isPrivate?: boolean } = { isPrivate: false } + let eventsWhere: { orgId: string; isPrivate?: boolean } = { orgId, isPrivate: false } if (isAdmin) delete eventsWhere.isPrivate let events = await prisma.event.findMany({ where: eventsWhere, @@ -118,7 +118,7 @@ export const loader = async ({ request }: LoaderArgs) => { return json({ events, - horses: await prisma.horse.findMany(), + horses: await prisma.horse.findMany({ where: { orgId } }), instructors, }) } @@ -158,6 +158,7 @@ const createEventSchema = z.object({ export async function action({ request }: ActionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) const body = await request.formData() const submission = formParse(body, { schema: () => { @@ -236,6 +237,7 @@ export async function action({ request }: ActionArgs) { title, start: dateTime.start, end: dateTime.end, + orgId, instructors: { connect: instructorData, }, diff --git a/app/routes/resources+/event-register.tsx b/app/routes/resources+/event-register.tsx index b88cd54..dac70aa 100644 --- a/app/routes/resources+/event-register.tsx +++ b/app/routes/resources+/event-register.tsx @@ -1,5 +1,5 @@ import { z } from 'zod' -import { requireUserId } from '~/utils/auth.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { parse } from '@conform-to/zod' import { json, type DataFunctionArgs } from '~/remix.ts' import { prisma } from '~/utils/db.server.ts' @@ -30,7 +30,7 @@ const EventRegistrationSchema = z.object({ }) export async function action({ request }: DataFunctionArgs) { - const userId = await requireUserId(request) + const { userId, orgId } = await requireOrgMember(request) const formData = await request.formData() const submission = parse(formData, { schema: EventRegistrationSchema, @@ -55,6 +55,10 @@ export async function action({ request }: DataFunctionArgs) { } if (submission.value._action === 'unregister') { + // Verify the event belongs to the user's org before mutating + const eventCheck = await prisma.event.findFirst({ where: { id: submission.value.eventId, orgId } }) + if (!eventCheck) throw json({ error: 'Event not found' }, { status: 404 }) + const event = await prisma.event.update({ where: { id: submission.value.eventId, @@ -88,6 +92,7 @@ export async function action({ request }: DataFunctionArgs) { event: event, role: submission.value.role, action: 'unregister', + orgId, }) return json( { @@ -109,6 +114,10 @@ export async function action({ request }: DataFunctionArgs) { } } + // Verify the event belongs to the user's org before mutating + const eventOrgCheck = await prisma.event.findFirst({ where: { id: submission.value.eventId, orgId } }) + if (!eventOrgCheck) throw json({ error: 'Event not found' }, { status: 404 }) + const event = await prisma.event.update({ where: { id: submission.value.eventId, @@ -162,6 +171,7 @@ export async function action({ request }: DataFunctionArgs) { event: event, role: submission.value.role, action: 'register', + orgId, }) return json( @@ -221,14 +231,16 @@ async function notifyAdmins({ role, action, user, + orgId, }: { event: Event role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'horseLeaders' action: 'register' | 'unregister' user: User + orgId: string }) { const admins = await prisma.user.findMany({ - where: { roles: { some: { name: 'admin' } } }, + where: { orgId, roles: { some: { name: 'admin' } } }, }) for (const admin of admins) { diff --git a/prisma/seed.ts b/prisma/seed.ts index ec39efb..05f3526 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -95,7 +95,7 @@ async function seed() { const user = await prisma.user.create({ data: { ...userData, - orgId: defaultOrg.id, + org: { connect: { id: defaultOrg.id } }, password: { create: createPassword(userData.username), }, @@ -126,7 +126,7 @@ async function seed() { email: 'kody@kcd.dev', username: 'kody', name: 'Kody', - orgId: defaultOrg.id, + org: { connect: { id: defaultOrg.id } }, roles: { connect: { id: adminRole.id } }, image: { create: { @@ -159,7 +159,7 @@ async function seed() { email: 'bob@not.admin', username: 'bob', name: 'Bob', - orgId: defaultOrg.id, + org: { connect: { id: defaultOrg.id } }, image: { create: { contentType: 'image/png', @@ -190,7 +190,7 @@ async function seed() { email: 'isabelle@is.instructor', username: 'isabelle', name: 'Isabelle', - orgId: defaultOrg.id, + org: { connect: { id: defaultOrg.id } }, roles: { connect: { id: instructorRole.id } }, image: { create: { @@ -223,7 +223,7 @@ async function seed() { const horse = await prisma.horse.create({ data: { ...horseData, - orgId: defaultOrg.id, + org: { connect: { id: defaultOrg.id } }, image: { create: { contentType: 'image/jpeg', @@ -259,7 +259,7 @@ async function seed() { const event = await prisma.event.create({ data: { ...eventData, - orgId: defaultOrg.id, + org: { connect: { id: defaultOrg.id } }, }, }) return event From 7fe26d66462d03d6d36d435f2aed0e081d9d97ff Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:10:32 -0700 Subject: [PATCH 04/26] feat: rename Horse model to Animal with species field --- .../migration.sql | 133 ++++++++++++++++++ prisma/schema.prisma | 41 +++--- 2 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql diff --git a/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql b/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql new file mode 100644 index 0000000..49271fe --- /dev/null +++ b/prisma/migrations/20260313030000_rename_horse_to_animal/migration.sql @@ -0,0 +1,133 @@ +-- DropIndex +DROP INDEX "Horse_imageId_key"; + +-- DropIndex +DROP INDEX "Horse_id_key"; + +-- DropIndex +DROP INDEX "HorseAssignment_eventId_userId_key"; + +-- DropIndex +DROP INDEX "HorseAssignment_id_key"; + +-- DropIndex +DROP INDEX "_EventToHorse_B_index"; + +-- DropIndex +DROP INDEX "_EventToHorse_AB_unique"; + +-- DropIndex +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 +CREATE TABLE "Animal" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "species" TEXT NOT NULL DEFAULT 'horse', + "notes" TEXT, + "status" TEXT, + "updatedAt" DATETIME NOT NULL, + "cooldown" BOOLEAN NOT NULL DEFAULT false, + "cooldownStartDate" DATETIME, + "cooldownEndDate" DATETIME, + "orgId" TEXT, + "imageId" TEXT, + CONSTRAINT "Animal_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Animal_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image" ("fileId") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AnimalAssignment" ( + "id" TEXT NOT NULL PRIMARY KEY, + "eventId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "animalId" TEXT NOT NULL, + CONSTRAINT "AnimalAssignment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AnimalAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "AnimalAssignment_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_AnimalToEvent" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_AnimalToEvent_A_fkey" FOREIGN KEY ("A") REFERENCES "Animal" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_AnimalToEvent_B_fkey" FOREIGN KEY ("B") REFERENCES "Event" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "_animalHandler" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_animalHandler_A_fkey" FOREIGN KEY ("A") REFERENCES "Event" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_animalHandler_B_fkey" FOREIGN KEY ("B") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Event" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "start" DATETIME NOT NULL, + "end" DATETIME NOT NULL, + "orgId" TEXT, + "cleaningCrewReq" INTEGER NOT NULL DEFAULT 0, + "lessonAssistantsReq" INTEGER NOT NULL DEFAULT 0, + "sideWalkersReq" INTEGER NOT NULL DEFAULT 0, + "animalHandlersReq" INTEGER NOT NULL DEFAULT 0, + "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"; +DROP TABLE "Event"; +ALTER TABLE "new_Event" RENAME TO "Event"; +CREATE UNIQUE INDEX "Event_id_key" ON "Event"("id"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; + +-- CreateIndex +CREATE UNIQUE INDEX "Animal_id_key" ON "Animal"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "Animal_imageId_key" ON "Animal"("imageId"); + +-- CreateIndex +CREATE UNIQUE INDEX "AnimalAssignment_id_key" ON "AnimalAssignment"("id"); + +-- CreateIndex +CREATE UNIQUE INDEX "AnimalAssignment_eventId_userId_key" ON "AnimalAssignment"("eventId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_AnimalToEvent_AB_unique" ON "_AnimalToEvent"("A", "B"); + +-- CreateIndex +CREATE INDEX "_AnimalToEvent_B_index" ON "_AnimalToEvent"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_animalHandler_AB_unique" ON "_animalHandler"("A", "B"); + +-- CreateIndex +CREATE INDEX "_animalHandler_B_index" ON "_animalHandler"("B"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f2c9d52..5419114 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,9 +20,9 @@ model Organization { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - users User[] - horses Horse[] - events Event[] + users User[] + animals Animal[] + events Event[] } @@ -45,8 +45,8 @@ model Image { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User? - horse Horse? + user User? + animal Animal? } model Role { @@ -98,13 +98,13 @@ model User { lastLogin DateTime? @default(now()) - Instructor Event[] @relation("instructor") - cleaningCrew Event[] @relation("cleaningCrew") - lessonAssistant Event[] @relation("lessonAssistant") - sideWalker Event[] @relation("sideWalker") - horseLeader Event[] @relation("horseLeader") + Instructor Event[] @relation("instructor") + cleaningCrew Event[] @relation("cleaningCrew") + lessonAssistant Event[] @relation("lessonAssistant") + sideWalker Event[] @relation("sideWalker") + animalHandler Event[] @relation("animalHandler") - horseAssignments HorseAssignment[] + animalAssignments AnimalAssignment[] } model Password { @@ -150,9 +150,10 @@ model Session { expirationDate DateTime } -model Horse { +model Animal { id String @id @unique @default(cuid()) name String + species String @default("horse") notes String? status String? updatedAt DateTime @updatedAt @@ -167,7 +168,7 @@ model Horse { imageId String? @unique events Event[] - HorseAssignment HorseAssignment[] + AnimalAssignment AnimalAssignment[] } model Event { @@ -191,24 +192,24 @@ model Event { sideWalkers User[] @relation("sideWalker") sideWalkersReq Int @default(0) - horseLeaders User[] @relation("horseLeader") - horseLeadersReq Int @default(0) + animalHandlers User[] @relation("animalHandler") + animalHandlersReq Int @default(0) - horses Horse[] + animals Animal[] - horseAssignments HorseAssignment[] + animalAssignments AnimalAssignment[] isPrivate Boolean @default(false) } -model HorseAssignment { +model AnimalAssignment { id String @id @unique @default(cuid()) event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) volunteer User @relation(fields: [userId], references: [id], onDelete: Cascade) - horse Horse @relation(fields: [horseId], references: [id], onDelete: Cascade) + animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade) eventId String userId String - horseId String + animalId String @@unique([eventId, userId]) } From 48dbc038d5aa4453d0410c54b8fdb726144b26b9 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:36:04 -0700 Subject: [PATCH 05/26] refactor: rename horse admin routes and utilities to animal - Rename _horses+ route directory to _animals+ with animalId params - Rename HorseListbox to AnimalListbox in listboxes component - Rename horseDateConflicts/renderHorseConflictMessage to animal equivalents - Update createHorse -> createAnimal in db-utils and seed - Update seed.ts to use prisma.animal.create Co-Authored-By: George Badulescu --- app/components/listboxes.tsx | 30 +++---- .../animals.delete.$animalId.tsx} | 46 +++++------ .../animals.edit.$animalId.tsx} | 82 +++++++++---------- .../horses.tsx => _animals+/animals.tsx} | 52 ++++++------ app/utils/cooldown-functions.ts | 30 +++---- prisma/seed.ts | 26 +++--- tests/db-utils.ts | 35 ++++---- 7 files changed, 149 insertions(+), 152 deletions(-) rename app/routes/admin+/{_horses+/horses.delete.$horseId.tsx => _animals+/animals.delete.$animalId.tsx} (77%) rename app/routes/admin+/{_horses+/horses.edit.$horseId.tsx => _animals+/animals.edit.$animalId.tsx} (78%) rename app/routes/admin+/{_horses+/horses.tsx => _animals+/animals.tsx} (84%) diff --git a/app/components/listboxes.tsx b/app/components/listboxes.tsx index bdb0049..1f2e3d6 100644 --- a/app/components/listboxes.tsx +++ b/app/components/listboxes.tsx @@ -8,27 +8,27 @@ const listboxButtonClassName = const listBoxOptionsClassname = 'z-50 absolute mt-1 max-h-60 w-full overflow-auto rounded-md py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm border-input border border-1' -interface HorseData { +interface AnimalData { id: string name: string } -interface HorseListboxProps { - horses: HorseData[] +interface AnimalListboxProps { + animals: AnimalData[] name: string - defaultValues?: HorseData[] + defaultValues?: AnimalData[] error: boolean } -export function HorseListbox({ - horses, +export function AnimalListbox({ + animals, name, defaultValues = [], error, -}: HorseListboxProps) { - const initialValues = horses.filter(horse => { +}: AnimalListboxProps) { + const initialValues = animals.filter(animal => { for (const value of defaultValues) { - if (value.id == horse.id) { + if (value.id == animal.id) { return true } } @@ -51,7 +51,7 @@ export function HorseListbox({ aria-invalid={error ? true : undefined} > - {selected.map(horse => horse.name).join(', ')} + {selected.map(animal => animal.name).join(', ')} @@ -65,22 +65,22 @@ export function HorseListbox({ leaveTo="opacity-0" > - {horses.map((horse, horseIdx) => ( + {animals.map((animal, animalIdx) => ( `relative cursor-default select-none py-2 pl-10 pr-4 ${active ? 'bg-teal-600 text-white' : 'bg-background text-primary'}` } - value={horse} + value={animal} > {({ selected, active }) => ( <> - {horse.name} + {animal.name} {selected ? ( { await requireAdmin(request) const { orgId } = await requireOrgMember(request) - invariant(params.horseId, 'Missing horse id') - const horse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) - if (!horse) { + invariant(params.animalId, 'Missing animal id') + const animal = await prisma.animal.findFirst({ where: { id: params.animalId, orgId } }) + if (!animal) { throw new Response('not found', { status: 404 }) } - return json({ horse }) + return json({ animal }) } -export const deleteHorseFormSchema = z.object({ +export const deleteAnimalFormSchema = z.object({ name: z .string() .min(1, { message: - 'You must enter the name of this horse to delete it from the database.', + 'You must enter the name of this animal to delete it from the database.', }), }) export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) const { orgId } = await requireOrgMember(request) - invariant(params.horseId, 'Missing horse id') + invariant(params.animalId, 'Missing animal id') const formData = await request.formData() - const horse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) - if (!horse) { + const animal = await prisma.animal.findFirst({ where: { id: params.animalId, orgId } }) + if (!animal) { throw new Response('not found', { status: 404 }) } const submission = await parse(formData, { async: true, - schema: deleteHorseFormSchema.superRefine(async ({ name }, ctx) => { - if (horse.name != name) { + schema: deleteAnimalFormSchema.superRefine(async ({ name }, ctx) => { + if (animal.name != name) { ctx.addIssue({ path: ['name'], code: 'custom', @@ -85,26 +85,26 @@ export async function action({ request, params }: DataFunctionArgs) { ) } - let deletedHorse + let deletedAnimal try { - deletedHorse = await prisma.horse.delete({ - where: { id: params.horseId }, + deletedAnimal = await prisma.animal.delete({ + where: { id: params.animalId }, }) } catch { - return redirectWithToast('/admin/horses', { + return redirectWithToast('/admin/animals', { title: 'Error', variant: 'destructive', - description: 'Failed to delete horse', + description: 'Failed to delete animal', }) } - return redirectWithToast('/admin/horses', { + return redirectWithToast('/admin/animals', { title: 'Success', - description: `Deleted horse ${deletedHorse.name}`, + description: `Deleted animal ${deletedAnimal.name}`, }) } -export default function DeleteHorse() { +export default function DeleteAnimal() { const data = useLoaderData() || {} const actionData = useActionData() const [open, setOpen] = useState(true) @@ -123,7 +123,7 @@ export default function DeleteHorse() { navigate('..', { preventScrollReset: true }) } const [form, fields] = useForm({ - id: 'edit-horse', + id: 'delete-animal', lastSubmission: actionData?.submission, shouldRevalidate: 'onSubmit', }) @@ -135,9 +135,9 @@ export default function DeleteHorse() { onPointerDownOutside={dismissModal} > - Delete Horse + Delete Animal - Are you sure you want to remove {data.horse?.name} from the + Are you sure you want to remove {data.animal?.name} from the database? This will affect all associated events and assignments. @@ -145,7 +145,7 @@ export default function DeleteHorse() { { await requireAdmin(request) const { orgId } = await requireOrgMember(request) - invariant(params.horseId, 'Missing horse id') - const horse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) - if (!horse) { + invariant(params.animalId, 'Missing animal id') + const animal = await prisma.animal.findFirst({ where: { id: params.animalId, orgId } }) + if (!animal) { throw new Response('not found', { status: 404 }) } - return json({ horse }) + return json({ animal }) } export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) const { orgId } = await requireOrgMember(request) - invariant(params.horseId, 'Missing horse id') + invariant(params.animalId, 'Missing animal id') const formData = await request.formData() const submission = await parse(formData, { async: true, - schema: horseFormSchema, + schema: animalFormSchema, }) if (submission.intent !== 'submit') { @@ -82,13 +82,12 @@ export async function action({ request, params }: DataFunctionArgs) { cooldownEndDate, } = submission.value - // Verify the horse belongs to the admin's org before updating - const existingHorse = await prisma.horse.findFirst({ where: { id: params.horseId, orgId } }) - if (!existingHorse) { + const existingAnimal = await prisma.animal.findFirst({ where: { id: params.animalId, orgId } }) + if (!existingAnimal) { throw new Response('not found', { status: 404 }) } - const updatedHorse = await prisma.horse.update({ - where: { id: params.horseId }, + const updatedAnimal = await prisma.animal.update({ + where: { id: params.animalId }, data: { name, status, @@ -99,47 +98,44 @@ export async function action({ request, params }: DataFunctionArgs) { }, }) - if (!updatedHorse) { - return redirectWithToast(`/admin/horses`, { + if (!updatedAnimal) { + return redirectWithToast(`/admin/animals`, { title: `Error`, variant: 'destructive', - description: `Failed to update horse`, + description: `Failed to update animal`, }) } if (cooldown && cooldownStartDate && cooldownEndDate) { - // Get events horseID is registered for - const horseEvents = await prisma.event.findMany({ + const animalEvents = await prisma.event.findMany({ where: { - horses: { + animals: { some: { - id: updatedHorse.id, + id: updatedAnimal.id, }, }, }, }) - // Compare event dates to cooldown dates and gather events with conflicts const conflictEvents = [] - if (horseEvents) { - for (const e of horseEvents) { + if (animalEvents) { + for (const e of animalEvents) { if ( cooldownStartDate <= e.start && - e.start < add(cooldownEndDate, { days: 1 }) // checks that event is before midnight of cooldownEndDate + e.start < add(cooldownEndDate, { days: 1 }) ) { conflictEvents.push(e) } } } - // Remove horse from events with conflicts if (conflictEvents.length > 0) { for (const e of conflictEvents) { await prisma.event.update({ where: { id: e.id }, data: { - horses: { - disconnect: { id: updatedHorse.id }, + animals: { + disconnect: { id: updatedAnimal.id }, }, }, }) @@ -152,13 +148,13 @@ export async function action({ request, params }: DataFunctionArgs) { } } - return redirectWithToast(`/admin/horses`, { + return redirectWithToast(`/admin/animals`, { title: `Success`, - description: `Updated ${updatedHorse.name}`, + description: `Updated ${updatedAnimal.name}`, }) } -export default function EditHorse() { +export default function EditAnimal() { const data = useLoaderData() || {} const actionData = useActionData() const [open, setOpen] = useState(true) @@ -177,31 +173,27 @@ export default function EditHorse() { navigate('..', { preventScrollReset: true }) } const [form, fields] = useForm({ - id: 'edit-horse', + id: 'edit-animal', lastSubmission: actionData?.submission, defaultValue: { - name: data.horse?.name, - status: data.horse?.status, - notes: data.horse?.notes, - cooldownStartDate: data.horse?.cooldownStartDate - ? format(new Date(data.horse.cooldownStartDate), 'yyyy-MM-dd') + name: data.animal?.name, + status: data.animal?.status, + notes: data.animal?.notes, + cooldownStartDate: data.animal?.cooldownStartDate + ? format(new Date(data.animal.cooldownStartDate), 'yyyy-MM-dd') : null, - cooldownEndDate: data.horse?.cooldownEndDate - ? format(new Date(data.horse.cooldownEndDate), 'yyyy-MM-dd') + cooldownEndDate: data.animal?.cooldownEndDate + ? format(new Date(data.animal.cooldownEndDate), 'yyyy-MM-dd') : null, }, shouldRevalidate: 'onSubmit', onSubmit: dismissModal, }) - /** - * If there is returned actionData (form validation errors), - * use that checked state, otherwise use the boolean from the DB - */ const cooldown = actionData ? actionData.submission.payload?.cooldown === 'on' ? true : false - : data.horse?.cooldown + : data.animal?.cooldown const [cooldownChecked, setCooldownChecked] = useState(cooldown) const conflictEvents = actionData?.conflictEvents ?? null @@ -212,9 +204,9 @@ export default function EditHorse() { onPointerDownOutside={dismissModal} > - Edit Horse: {data.horse?.name} + Edit Animal: {data.animal?.name} - Edit this horse using this form. Click save to save your changes. + Edit this animal using this form. Click save to save your changes.
@@ -296,7 +288,7 @@ export default function EditHorse() { - Horse removed from {conflictEvents.length}{' '} + Animal removed from {conflictEvents.length}{' '} {conflictEvents.length === 1 ? 'event' : 'events'} diff --git a/app/routes/admin+/_horses+/horses.tsx b/app/routes/admin+/_animals+/animals.tsx similarity index 84% rename from app/routes/admin+/_horses+/horses.tsx rename to app/routes/admin+/_animals+/animals.tsx index cdfa1b2..8e64016 100644 --- a/app/routes/admin+/_horses+/horses.tsx +++ b/app/routes/admin+/_animals+/animals.tsx @@ -32,7 +32,7 @@ import { useResetCallback } from '~/utils/misc.ts' import { useToast } from '~/components/ui/use-toast.ts' import { type ColumnDef } from '@tanstack/react-table' -import { type Horse } from '@prisma/client' +import { type Animal } from '@prisma/client' import { formatRelative } from 'date-fns' import { Icon } from '~/components/ui/icon.tsx' import { @@ -49,10 +49,11 @@ import { optionalDateTimeZoneSchema, } from '~/utils/zod-extensions.ts' -export const horseFormSchema = z +export const animalFormSchema = z .object({ _action: z.enum(['create', 'update']), name: z.string().min(1, { message: 'Name is required' }), + species: z.string().optional(), notes: z.string().optional(), status: z.string().optional(), cooldown: checkboxSchema(), @@ -60,10 +61,6 @@ export const horseFormSchema = z cooldownEndDate: optionalDateTimeZoneSchema, }) .refine( - /** - * This makes sure that if cooldown is checked, there is both a start and end date, - * and if cooldown is not checked, there are no start and end dates - */ schema => (schema.cooldownStartDate === null && schema.cooldownEndDate === null && @@ -79,9 +76,6 @@ export const horseFormSchema = z }, ) .refine( - /** - * Checks that end date is after or equal to start date - */ schema => { const { cooldownStartDate, cooldownEndDate } = schema if (cooldownStartDate && cooldownEndDate) { @@ -89,23 +83,23 @@ export const horseFormSchema = z } else return true }, { - message: "End date must not be before start date." - } + message: 'End date must not be before start date.', + }, ) export const loader = async ({ request }: DataFunctionArgs) => { await requireAdmin(request) const { orgId } = await requireOrgMember(request) - return json(await prisma.horse.findMany({ where: { orgId } })) + return json(await prisma.animal.findMany({ where: { orgId } })) } -export default function Horses() { +export default function Animals() { const data = useLoaderData() return (
-

Horses

+

Animals

- +
@@ -119,14 +113,15 @@ export const action = async ({ request }: ActionArgs) => { await requireAdmin(request) const { orgId } = await requireOrgMember(request) const formData = await request.formData() - const submission = parse(formData, { schema: horseFormSchema }) + const submission = parse(formData, { schema: animalFormSchema }) if (!submission.value) { return json({ status: 'error', submission } as const, { status: 400 }) } - await prisma.horse.create({ + await prisma.animal.create({ data: { name: submission.value.name, + species: submission.value.species ?? 'horse', notes: submission.value.notes, status: submission.value.status, orgId, @@ -142,7 +137,7 @@ export const action = async ({ request }: ActionArgs) => { ) } -function CreateHorseDialog() { +function CreateAnimalDialog() { const [open, setOpen] = useState(false) const actionData = useActionData() const { toast } = useToast() @@ -153,15 +148,15 @@ function CreateHorseDialog() { if (actionData.status == 'ok') { toast({ title: 'Success', - description: `Created horse "${actionData.submission?.value?.name}".`, + description: `Added animal "${actionData.submission?.value?.name}".`, }) setOpen(false) } else { if (actionData.submission.value?._action == 'create') { toast({ variant: 'destructive', - title: 'Error creating horse', - description: 'Failed to create horse. There was an unexpected error.', + title: 'Error adding animal', + description: 'Failed to add animal. There was an unexpected error.', }) } } @@ -172,14 +167,14 @@ function CreateHorseDialog() { - Register new horse + Add new animal - Fill out this form to add a new horse to the database. + Fill out this form to add a new animal to the roster. @@ -187,6 +182,9 @@ function CreateHorseDialog() { + + + @@ -202,11 +200,15 @@ function CreateHorseDialog() { ) } -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ { accessorKey: 'name', header: 'name', }, + { + accessorKey: 'species', + header: 'species', + }, { accessorKey: 'notes', header: 'notes', diff --git a/app/utils/cooldown-functions.ts b/app/utils/cooldown-functions.ts index 8744e49..bce1f50 100644 --- a/app/utils/cooldown-functions.ts +++ b/app/utils/cooldown-functions.ts @@ -1,42 +1,42 @@ import { add, format } from 'date-fns' -interface Horse { +interface Animal { id: String name: String cooldownStartDate?: Date | undefined cooldownEndDate?: Date | undefined } -export function isCooldownDateConflict(horse: Horse, date: Date) { - if (horse.cooldownStartDate && horse.cooldownEndDate) { +export function isCooldownDateConflict(animal: Animal, date: Date) { + if (animal.cooldownStartDate && animal.cooldownEndDate) { if ( - horse.cooldownStartDate <= date && - date < add(horse.cooldownEndDate, { days: 1 }) + animal.cooldownStartDate <= date && + date < add(animal.cooldownEndDate, { days: 1 }) ) return true } return false } -export function horseDateConflicts(horse: Horse, datesArr: Array) { +export function animalDateConflicts(animal: Animal, datesArr: Array) { let conflictingDatesArr = datesArr.filter(date => - isCooldownDateConflict(horse, date), + isCooldownDateConflict(animal, date), ) if (conflictingDatesArr.length > 0) { - return { name: horse.name, conflictingDatesArr } + return { name: animal.name, conflictingDatesArr } } else return null } -export function renderHorseConflictMessage( - horseArr: Array<{ name: String; conflictingDatesArr: Array }>, +export function renderAnimalConflictMessage( + animalArr: Array<{ name: String; conflictingDatesArr: Array }>, ) { let message = '' - horseArr.forEach(horse => { - const datesString = horse.conflictingDatesArr - .sort((a:Date, b:Date) => a.valueOf() - b.valueOf()) + animalArr.forEach(animal => { + const datesString = animal.conflictingDatesArr + .sort((a: Date, b: Date) => a.valueOf() - b.valueOf()) .map(date => format(date, 'PP')) .join(', ') - message = message + `${horse.name} (${datesString}), ` + message = message + `${animal.name} (${datesString}), ` }) - return message.slice(0, -2); + return message.slice(0, -2) } diff --git a/prisma/seed.ts b/prisma/seed.ts index 05f3526..c875108 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -3,7 +3,7 @@ import { faker } from '@faker-js/faker' import { createPassword, createUser, - createHorse, + createAnimal, createEvent, } from 'tests/db-utils.ts' import { prisma } from '~/utils/db.server.ts' @@ -65,16 +65,16 @@ async function seed() { }) console.timeEnd(`Created lesson assistant role/permission...`) - console.time(`Created horse leader role/permission...`) - const horseLeaderRole = await prisma.role.create({ + console.time(`Created animal handler role/permission...`) + const animalHandlerRole = await prisma.role.create({ data: { - name: 'horseLeader', + name: 'animalHandler', permissions: { - create: { name: 'horseLeader' }, + create: { name: 'animalHandler' }, }, }, }) - console.timeEnd(`Created horse leader role/permission...`) + console.timeEnd(`Created animal handler role/permission...`) console.time(`Created instructor role/permission...`) const instructorRole = await prisma.role.create({ @@ -219,10 +219,10 @@ async function seed() { console.time(`๐Ÿด Created ${totalHorses} horses...`) const horses = await Promise.all( Array.from({ length: totalHorses }, async (_, index) => { - const horseData = createHorse() - const horse = await prisma.horse.create({ + const animalData = createAnimal() + const animal = await prisma.animal.create({ data: { - ...horseData, + ...animalData, org: { connect: { id: defaultOrg.id } }, image: { create: { @@ -238,7 +238,7 @@ async function seed() { }, }, }) - return horse + return animal }), ) console.timeEnd(`๐Ÿด Created ${totalHorses} horses...`) @@ -267,13 +267,13 @@ async function seed() { ) console.timeEnd(`๐Ÿ“… Created a few events in the current month`) - console.time(`Setting signup password to "horses are cool"`) + console.time(`Setting signup password to "animals are cool"`) await prisma.signupPassword.create({ data: { - hash: await getPasswordHash('horses are cool'), + hash: await getPasswordHash('animals are cool'), } }) - console.timeEnd(`Setting signup password to "horses are cool"`) + console.timeEnd(`Setting signup password to "animals are cool"`) console.timeEnd(`๐ŸŒฑ Database has been seeded`) diff --git a/tests/db-utils.ts b/tests/db-utils.ts index 63d1e64..0263faa 100644 --- a/tests/db-utils.ts +++ b/tests/db-utils.ts @@ -21,10 +21,10 @@ export function createUser() { .replace(/[^a-z0-9_]/g, '_') const notes = [ - 'Great with horses. A real horse whisperer.', - 'Very enthusiastic, does well with more active horses.', - 'Very gentle, works well with timid horses. ', - 'Still a bit afraid of horses, needs some support from others.', + 'Great with animals. Very calm and patient.', + 'Very enthusiastic, does well with more active animals.', + 'Very gentle, works well with timid animals.', + 'Still a bit nervous around animals, needs some support from others.', ] return { @@ -45,7 +45,7 @@ export function createPassword(username: string = faker.internet.userName()) { } } -export function createHorse() { +export function createAnimal() { const name = faker.person.firstName() const exampleStatuses = [ @@ -57,11 +57,11 @@ export function createHorse() { "Tired. Don't schedule for consecutive events.", ] const exampleNotes = [ - 'A little ornery; needs experienced, careful riders and handlers.', - 'Very easy going. Good for beginner handlers and riders.', - 'Easily spooked, riders and handlers need to be aware of their surroundings.', + 'A little ornery; needs experienced, careful handlers.', + 'Very easy going. Good for beginner handlers.', + 'Easily startled, handlers need to be aware of their surroundings.', 'Very social. Needs a firm handler.', - 'Is a very big and active horse.', + 'Very active and energetic.', ] const notes = exampleNotes[Math.floor(Math.random() * exampleNotes.length)] @@ -75,12 +75,15 @@ export function createHorse() { } } +/** @deprecated Use createAnimal instead */ +export const createHorse = createAnimal + export async function createEvent(start: Date) { const volunteers = await prisma.user.findMany({ where: { roles: { none: {} }}, }) - const allHorses = await prisma.horse.findMany() - const horses = faker.helpers.arrayElements(allHorses, { min: 3, max: 5 }) + const allAnimals = await prisma.animal.findMany() + const animals = faker.helpers.arrayElements(allAnimals, { min: 3, max: 5 }) const duration = faker.helpers.arrayElement([30, 60, 90]) const instructors = await prisma.user.findMany({ @@ -117,14 +120,14 @@ export async function createEvent(start: Date) { instructors: { connect: { id: instructor.id }, }, - horses: { - connect: horses.map(horse => { - return { id: horse.id } + animals: { + connect: animals.map(animal => { + return { id: animal.id } }), }, cleaningCrewReq: reqs[0], lessonAssistantsReq: reqs[1], - horseLeadersReq: reqs[2], + animalHandlersReq: reqs[2], sideWalkersReq: reqs[3], cleaningCrew: { @@ -133,7 +136,7 @@ export async function createEvent(start: Date) { lessonAssistants: { connect: assignments[1], }, - horseLeaders: { + animalHandlers: { connect: assignments[2], }, sideWalkers: { From 96fe1eed1b944ea5d6e6cb49c5682f4d14051db3 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:36:44 -0700 Subject: [PATCH 06/26] refactor: update data.ts and volunteer descriptions to animal-agnostic copy - Rename HorseData -> AnimalData, HorseAssignment -> AnimalAssignment - Update volunteerTypes: horse leaders -> animal handlers with generic copy - Update siteName to TrotTrack Volunteer Portal, siteBaseUrl to trottrack.org - Rename getHorseImgSrc -> getAnimalImgSrc in misc.ts - Update EventWithAllRelations/EventWithVolunteers to use animals, animalHandlers Co-Authored-By: George Badulescu --- app/data.ts | 40 ++++++++++++++++++++-------------------- app/utils/misc.ts | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/app/data.ts b/app/data.ts index bc8719d..ac9da9a 100644 --- a/app/data.ts +++ b/app/data.ts @@ -1,10 +1,10 @@ import { Prisma } from '@prisma/client' -export const siteName = 'The Barn Volunteer Portal' +export const siteName = 'TrotTrack Volunteer Portal' export const siteEmailAddress = 'hello@email.trottrack.org' export const siteEmailAddressWithName = siteName + ' ' -export const siteBaseUrl = 'https://thebarn.trottrack.org' +export const siteBaseUrl = 'https://trottrack.org' export const volunteerTypes = [ { @@ -12,28 +12,28 @@ export const volunteerTypes = [ field: 'cleaningCrew', reqField: 'cleaningCrewReq', description: - 'Cleaning crew volunteers help clean all pastures and stalls in the barn, check automatic waterers, sweep the feed room and tack room, and handle other miscellaneous cleaning jobs. No prior experience with horses is required.', + '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 students helping to support them during lessons.No prior experience with horses needed. Must be able to walk on uneven surfaces.', + '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 horses. They must be able to groom and tack horses, and to communicate effectively with both students and instructors.', + 'Lesson assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.', }, { - displayName: 'horse leaders', - field: 'horseLeaders', - reqField: 'horseLeadersReq', + displayName: 'animal handlers', + field: 'animalHandlers', + reqField: 'animalHandlersReq', description: - 'Leads horses during lessons. Should have 1+ years of experiences with horses, and must be able to walk on uneven surfaces.', + '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.', }, ] as const @@ -49,7 +49,7 @@ export interface UserData { yearsOfExperience: number | null } -export interface HorseData { +export interface AnimalData { id: string name: string imageId: string | null @@ -60,9 +60,9 @@ export interface HorseData { cooldownEndDate: Date | null } -export interface HorseAssignment { +export interface AnimalAssignment { userId: string - horseId: string + animalId: string } export interface CalEvent { @@ -72,28 +72,28 @@ export interface CalEvent { end: Date instructors: UserData[] - horses: HorseData[] + animals: AnimalData[] cleaningCrewReq: number lessonAssistantsReq: number - horseLeadersReq: number + animalHandlersReq: number sideWalkersReq: number cleaningCrew: UserData[] lessonAssistants: UserData[] - horseLeaders: UserData[] + animalHandlers: UserData[] sideWalkers: UserData[] } const EventWithAllRelations = Prisma.validator()({ include: { - horses: true, + animals: true, instructors: true, cleaningCrew: true, lessonAssistants: true, - horseLeaders: true, + animalHandlers: true, sideWalkers: true, - horseAssignments: true, + animalAssignments: true, }, }) @@ -102,11 +102,11 @@ export type EventWithAllRelations = Prisma.EventGetPayload< > const EventWithVolunteers = Prisma.validator()({ include: { - horses: true, + animals: true, instructors: true, cleaningCrew: true, lessonAssistants: true, - horseLeaders: true, + animalHandlers: true, sideWalkers: true, }, }) diff --git a/app/utils/misc.ts b/app/utils/misc.ts index c54e7d9..b5d6382 100644 --- a/app/utils/misc.ts +++ b/app/utils/misc.ts @@ -6,7 +6,7 @@ export function getUserImgSrc(imageId?: string | null) { return imageId ? `/resources/file/${imageId}` : `/img/user.png` } -export function getHorseImgSrc(imageId?: string | null) { +export function getAnimalImgSrc(imageId?: string | null) { return imageId ? `/resources/file/${imageId}` : `/img/horse.png` } From f63f186a5719bf0852133901879c9b0c0ed208d0 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:36:53 -0700 Subject: [PATCH 07/26] refactor: update all route files to use animal naming conventions - calendar/$eventId.tsx: animals/animalAssignments/animalHandlers, AnimalInfoPopover - calendar/$eventId.edit.tsx: AnimalListbox, animals, animalHandlersReq - calendar/index.tsx: animals loader, animalHandlers filter/tooltip, AnimalListbox - event-register.tsx: animalHandlers role type union - registration/unregistration emails: animalHandlers role type union - users.edit: animalHandler role instead of horseLeader - users.tsx: animal handler column header - email.tsx: animalHandler field instead of horseLeader - root.tsx: nav link updated to /admin/animals Co-Authored-By: George Badulescu --- app/root.tsx | 4 +- app/routes/admin+/_email+/email.tsx | 12 +- .../admin+/_users+/users.edit.$userId.tsx | 8 +- app/routes/admin+/_users+/users.tsx | 4 +- app/routes/calendar+/$eventId.edit.tsx | 74 +++++------ app/routes/calendar+/$eventId.tsx | 116 +++++++++--------- app/routes/calendar+/index.tsx | 104 ++++++++-------- app/routes/resources+/event-register.tsx | 8 +- .../resources+/registration-emails.server.tsx | 4 +- .../unregistration-emails.server.tsx | 2 +- 10 files changed, 168 insertions(+), 168 deletions(-) diff --git a/app/root.tsx b/app/root.tsx index f8ec336..838a188 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -316,9 +316,9 @@ function AdminDropdown() { - + - Horses + Animals diff --git a/app/routes/admin+/_email+/email.tsx b/app/routes/admin+/_email+/email.tsx index 8389a13..16c98f7 100644 --- a/app/routes/admin+/_email+/email.tsx +++ b/app/routes/admin+/_email+/email.tsx @@ -29,7 +29,7 @@ const emailFormSchema = z .object({ allVolunteers: checkboxSchema(), lessonAssistant: checkboxSchema(), - horseLeader: checkboxSchema(), + animalHandler: checkboxSchema(), instructor: checkboxSchema(), subject: z .string() @@ -60,7 +60,7 @@ export async function action({ request, params }: DataFunctionArgs) { const roles = [ 'allVolunteers', 'lessonAssistant', - 'horseLeader', + 'animalHandler', 'instructor', ] const selectedRoles = roles.filter(role => submission.payload[role] === 'on') @@ -184,13 +184,13 @@ export default function Email() { /> [] = [ }, }, { - header: 'horse leader', + header: 'animal handler', accessorFn: (row) => { - const hasRole = row.roles.find(r => r.name === 'horseLeader') + const hasRole = row.roles.find(r => r.name === 'animalHandler') return hasRole ? 'Yes' : 'No' }, }, diff --git a/app/routes/calendar+/$eventId.edit.tsx b/app/routes/calendar+/$eventId.edit.tsx index c3b0ed9..28087b3 100644 --- a/app/routes/calendar+/$eventId.edit.tsx +++ b/app/routes/calendar+/$eventId.edit.tsx @@ -23,7 +23,7 @@ import { import { Icon } from '~/components/ui/icon.tsx' import { prisma } from '~/utils/db.server.ts' import { requireAdmin } from '~/utils/permissions.server.ts' -import { HorseListbox, InstructorListbox } from '~/components/listboxes.tsx' +import { AnimalListbox, InstructorListbox } from '~/components/listboxes.tsx' import { addMinutes, differenceInMinutes, format, add } from 'date-fns' import { redirectWithToast } from '~/utils/flash-session.server.ts' import { conform, useForm } from '@conform-to/react' @@ -49,11 +49,11 @@ export const loader = async ({ request, params }: DataFunctionArgs) => { const instructors = await prisma.user.findMany({ where: { roles: { some: { name: 'instructor' } } }, }) - const horses = await prisma.horse.findMany() + const animals = await prisma.animal.findMany() const event = await prisma.event.findUnique({ where: { id: params.eventId }, include: { - horses: true, + animals: true, instructors: true, }, }) @@ -61,10 +61,10 @@ export const loader = async ({ request, params }: DataFunctionArgs) => { if (!event) { throw new Response('not found', { status: 404 }) } - return json({ event, horses, instructors }) + return json({ event, animals, instructors }) } -const horseSchema = z.object({ +const animalSchema = z.object({ id: z.string(), name: z.string(), cooldownStartDate: optionalDateSchema, @@ -83,12 +83,12 @@ const editEventSchema = z.object({ title: z.string().min(1, 'Title is required'), startDate: z.coerce.date(), duration: z.coerce.number().gt(0), - horses: z.array(horseSchema).optional(), + animals: z.array(animalSchema).optional(), instructor: instructorSchema, cleaningCrewReq: z.coerce.number().gt(-1), lessonAssistantsReq: z.coerce.number().gt(-1), sideWalkersReq: z.coerce.number().gt(-1), - horseLeadersReq: z.coerce.number().gt(-1), + animalHandlersReq: z.coerce.number().gt(-1), isPrivate: checkboxSchema(), }) @@ -121,27 +121,27 @@ export async function action({ request, params }: DataFunctionArgs) { if (instructorId) { instructorData = [{ id: instructorId }] } - const horseIds = submission.value.horses?.map(e => { + const animalIds = submission.value.animals?.map(e => { return { id: e.id } }) - // check that horses selected are not in cooldown period - if (submission.value.horses) { - const selectedHorsesArray = submission.value.horses - const errorHorses = selectedHorsesArray.filter(horse => { - if (horse.cooldownStartDate && horse.cooldownEndDate) { + // check that animals selected are not in cooldown period + if (submission.value.animals) { + const selectedAnimalsArray = submission.value.animals + const errorAnimals = selectedAnimalsArray.filter(animal => { + if (animal.cooldownStartDate && animal.cooldownEndDate) { return ( - horse.cooldownStartDate <= start && - start < add(horse.cooldownEndDate, { days: 1 }) + animal.cooldownStartDate <= start && + start < add(animal.cooldownEndDate, { days: 1 }) ) } else return false }) - const listOfHorses = errorHorses.map(h => h.name).join(', ') - if (errorHorses.length > 0) { + const listOfAnimals = errorAnimals.map(a => a.name).join(', ') + if (errorAnimals.length > 0) { return json({ - status: 'horse-error', + status: 'animal-error', submission, - message: listOfHorses, + message: listOfAnimals, } as const) } } @@ -149,7 +149,7 @@ export async function action({ request, params }: DataFunctionArgs) { const cleaningCrewReq = submission.value.cleaningCrewReq const lessonAssistantsReq = submission.value.lessonAssistantsReq const sideWalkersReq = submission.value.sideWalkersReq - const horseLeadersReq = submission.value.horseLeadersReq + const animalHandlersReq = submission.value.animalHandlersReq const isPrivate = submission.value.isPrivate @@ -164,13 +164,13 @@ export async function action({ request, params }: DataFunctionArgs) { instructors: { set: instructorData, }, - horses: { - set: horseIds ?? [], + animals: { + set: animalIds ?? [], }, cleaningCrewReq, lessonAssistantsReq, sideWalkersReq, - horseLeadersReq, + animalHandlersReq, isPrivate, }, }) @@ -224,10 +224,10 @@ export default function EventEditor() { ? format(new Date(data.event.start), "yyyy-MM-dd'T'HH:mm:00") : '', duration: defaultDuration, - horses: data.event?.horses, + animals: data.event?.animals, instructor: data.event?.instructors[0], cleaningCrewReq: data.event?.cleaningCrewReq, - horseLeadersReq: data.event?.horseLeadersReq, + animalHandlersReq: data.event?.animalHandlersReq, sideWalkersReq: data.event?.sideWalkersReq, lessonAssistantsReq: data.event?.lessonAssistantsReq, }, @@ -238,11 +238,11 @@ export default function EventEditor() { if (!actionData) { return } - if (actionData.status === 'horse-error') { + if (actionData.status === 'animal-error') { toast({ variant: 'destructive', title: - 'The following horses are scheduled for cooldown on the selected dates:', + 'The following animals are scheduled for cooldown on the selected dates:', description: actionData.message, }) } @@ -298,12 +298,12 @@ export default function EventEditor() {
- - Animals +
@@ -354,14 +354,14 @@ export default function EventEditor() { { @@ -65,7 +65,7 @@ export const action = async ({ request, params }: ActionArgs) => { const formData = await request.formData() const submission = parse(formData, { schema: () => { - return assignHorseSchema + return assignAnimalSchema }, }) @@ -82,10 +82,10 @@ export const action = async ({ request, params }: ActionArgs) => { invariant(params.eventId, 'Expected params.eventId') const eventId = params.eventId const userId = submission.value.user - const horseId = submission.value.horse + const animalId = submission.value.animal - if (horseId === 'none') { - await prisma.horseAssignment.delete({ + if (animalId === 'none') { + await prisma.animalAssignment.delete({ where: { eventId_userId: { eventId, @@ -104,7 +104,7 @@ export const action = async ({ request, params }: ActionArgs) => { ) } - await prisma.horseAssignment.upsert({ + await prisma.animalAssignment.upsert({ where: { eventId_userId: { eventId, @@ -112,8 +112,8 @@ export const action = async ({ request, params }: ActionArgs) => { }, }, update: { - horse: { - connect: { id: horseId }, + animal: { + connect: { id: animalId }, }, }, create: { @@ -123,8 +123,8 @@ export const action = async ({ request, params }: ActionArgs) => { volunteer: { connect: { id: userId }, }, - horse: { - connect: { id: horseId }, + animal: { + connect: { id: animalId }, }, }, }) @@ -191,20 +191,20 @@ export default function () { ) })}
-
Horses:
+
Animals:
- {event.horses.map(horse => { + {event.animals.map(animal => { return ( - +
{horse.name} -
{horse.name}
+
{animal.name}
-
+ ) })}
@@ -270,7 +270,7 @@ export function VolunteerSection({ interface VolunteerListItemProps { user?: UserData event: CalEvent & { - horseAssignments: HorseAssignment[] + animalAssignments: AnimalAssignment[] } } @@ -293,20 +293,20 @@ function VolunteerListItem({ const isPlaceholder = user.id === 'placeholder' const assignmentFetcher = useFetcher() - let assignedHorseId = 'none' - let assignedHorseImageId = '' - let assignedHorse = null - for (const assignment of event.horseAssignments) { + let assignedAnimalId = 'none' + let assignedAnimalImageId = '' + let assignedAnimal = null + for (const assignment of event.animalAssignments) { if (assignment.userId === user.id) { - assignedHorseId = assignment.horseId + assignedAnimalId = assignment.animalId } } - if (assignedHorseId != 'none') { - for (const horse of event.horses) { - if (horse.id === assignedHorseId && horse.imageId) { - assignedHorseImageId = horse.imageId - assignedHorse = horse + if (assignedAnimalId != 'none') { + for (const animal of event.animals) { + if (animal.id === assignedAnimalId && animal.imageId) { + assignedAnimalImageId = animal.imageId + assignedAnimal = animal } } } @@ -320,7 +320,7 @@ function VolunteerListItem({ assignmentFetcher.submit( { user: user.id, - horse: target.value, + animal: target.value, }, { method: 'post' }, ) @@ -360,19 +360,19 @@ function VolunteerListItem({
{isSubmitting ? ( ๐ŸŒ€ - ) : assignedHorse ? ( - + ) : assignedAnimal ? ( + horse - + ) : ( horse )} @@ -380,14 +380,14 @@ function VolunteerListItem({ @@ -397,31 +397,31 @@ function VolunteerListItem({ ) } -interface HorseInfoPopoverProps { +interface AnimalInfoPopoverProps { children: React.ReactNode - horse: HorseData + animal: AnimalData } -function HorseInfoPopover({ children, horse }: HorseInfoPopoverProps) { +function AnimalInfoPopover({ children, animal }: AnimalInfoPopoverProps) { return ( {children} -
{horse.name}
+
{animal.name}
horse
Status: - {horse.status} + {animal.status}
Notes: - {horse.notes} + {animal.notes}
@@ -451,8 +451,8 @@ function VolunteerInfoPopover({
horse
Age: diff --git a/app/routes/calendar+/index.tsx b/app/routes/calendar+/index.tsx index e40a798..2313254 100644 --- a/app/routes/calendar+/index.tsx +++ b/app/routes/calendar+/index.tsx @@ -12,7 +12,7 @@ import { Icon } from '~/components/ui/icon.tsx' import { volunteerTypes, type UserData, - type HorseData, + type AnimalData, type EventWithVolunteers, } from '~/data.ts' import { useMemo, useState } from 'react' @@ -44,7 +44,7 @@ import { import { parse as formParse } from '@conform-to/zod' import { z } from 'zod' -import { HorseListbox, InstructorListbox } from '~/components/listboxes.tsx' +import { AnimalListbox, InstructorListbox } from '~/components/listboxes.tsx' import { addMinutes, isAfter } from 'date-fns' import { useFetcher, useFormAction, useNavigation } from '@remix-run/react' import { useResetCallback } from '~/utils/misc.ts' @@ -68,8 +68,8 @@ import { Separator } from '~/components/ui/separator.tsx' import { CheckboxField, Field, DatePickerField } from '~/components/forms.tsx' import { checkboxSchema, optionalDateSchema } from '~/utils/zod-extensions.ts' import { - horseDateConflicts, - renderHorseConflictMessage, + animalDateConflicts, + renderAnimalConflictMessage, } from '~/utils/cooldown-functions.ts' import { EventAgenda } from '~/components/EventAgenda.tsx' @@ -97,12 +97,12 @@ export const loader = async ({ request }: LoaderArgs) => { let events = await prisma.event.findMany({ where: eventsWhere, include: { - horses: true, + animals: true, instructors: true, cleaningCrew: true, lessonAssistants: true, sideWalkers: true, - horseLeaders: true, + animalHandlers: true, }, }) @@ -118,12 +118,12 @@ export const loader = async ({ request }: LoaderArgs) => { return json({ events, - horses: await prisma.horse.findMany({ where: { orgId } }), + animals: await prisma.animal.findMany({ where: { orgId } }), instructors, }) } -const horseSchema = z.object({ +const animalSchema = z.object({ id: z.string(), name: z.string(), cooldownStartDate: optionalDateSchema, @@ -147,12 +147,12 @@ const createEventSchema = z.object({ .string() .regex(new RegExp(/^\d{2}:\d{2}$/g), 'Invalid start time'), duration: z.coerce.number().gt(0), - horses: z.array(horseSchema).optional(), + animals: z.array(animalSchema).optional(), instructor: instructorSchema, cleaningCrewReq: z.coerce.number().gt(-1), lessonAssistantsReq: z.coerce.number().gt(-1), sideWalkersReq: z.coerce.number().gt(-1), - horseLeadersReq: z.coerce.number().gt(-1), + animalHandlersReq: z.coerce.number().gt(-1), isPrivate: checkboxSchema(), }) @@ -183,7 +183,7 @@ export async function action({ request }: ActionArgs) { const cleaningCrewReq = submission.value.cleaningCrewReq const lessonAssistantsReq = submission.value.lessonAssistantsReq const sideWalkersReq = submission.value.sideWalkersReq - const horseLeadersReq = submission.value.horseLeadersReq + const animalHandlersReq = submission.value.animalHandlersReq const isPrivate = submission.value.isPrivate const dateTimesArray = datesArray.map(date => { @@ -193,27 +193,27 @@ export async function action({ request }: ActionArgs) { return { start, end } }) - // Check that horses selected are not in cooldown period - const horses = submission.value.horses - if (horses) { - interface horseDateConflict { + // Check that animals selected are not in cooldown period + const animals = submission.value.animals + if (animals) { + interface animalDateConflict { name: String conflictingDatesArr: Array } - let errorHorseArr: Array = [] - horses.forEach(horse => { - const conflicts = horseDateConflicts( - horse, + let errorAnimalArr: Array = [] + animals.forEach(animal => { + const conflicts = animalDateConflicts( + animal, dateTimesArray.map(date => date.start), ) - if (conflicts) errorHorseArr.push(conflicts) + if (conflicts) errorAnimalArr.push(conflicts) }) - const message = renderHorseConflictMessage(errorHorseArr) + const message = renderAnimalConflictMessage(errorAnimalArr) - if (errorHorseArr.length > 0) { + if (errorAnimalArr.length > 0) { return json({ - status: 'horse-error', + status: 'animal-error', submission, message, } as const) @@ -225,7 +225,7 @@ export async function action({ request }: ActionArgs) { if (instructorId) { instructorData = [{ id: instructorId }] } - const horseIds = submission.value.horses?.map(e => { + const animalIds = submission.value.animals?.map(e => { return { id: e.id } }) @@ -241,13 +241,13 @@ export async function action({ request }: ActionArgs) { instructors: { connect: instructorData, }, - horses: { - connect: horseIds ?? [], + animals: { + connect: animalIds ?? [], }, cleaningCrewReq, lessonAssistantsReq, sideWalkersReq, - horseLeadersReq, + animalHandlersReq, isPrivate, }, }), @@ -267,7 +267,7 @@ export async function action({ request }: ActionArgs) { export default function Schedule() { const data = useLoaderData() var events = data.events - const horses = data.horses + const animals = data.animals const instructors = data.instructors const user = useUser() const userIsAdmin = user.roles.find(role => role.name === 'admin') @@ -281,7 +281,7 @@ export default function Schedule() { event.start.valueOf() > new Date().valueOf() && (event.cleaningCrewReq > event.cleaningCrew.length || event.lessonAssistantsReq > event.lessonAssistants.length || - event.horseLeadersReq > event.horseLeaders.length || + event.animalHandlersReq > event.animalHandlers.length || event.sideWalkersReq > event.sideWalkers.length) ) }) @@ -315,7 +315,7 @@ export default function Schedule() {
{userIsAdmin ? ( - + ) : null}
@@ -323,7 +323,7 @@ export default function Schedule() { localizer={localizer} events={filterFlag ? eventsThatNeedHelp : events} tooltipAccessor={event => - `Cleaning Crew: ${event.cleaningCrew.length} / ${event.cleaningCrewReq}\nSidewalkers: ${event.sideWalkers.length} / ${event.sideWalkersReq}\nLesson Assistants: ${event.lessonAssistants.length} / ${event.lessonAssistantsReq}\nHorse Leaders: ${event.horseLeaders.length} / ${event.horseLeadersReq}` + `Cleaning Crew: ${event.cleaningCrew.length} / ${event.cleaningCrewReq}\nSidewalkers: ${event.sideWalkers.length} / ${event.sideWalkersReq}\nLesson Assistants: ${event.lessonAssistants.length} / ${event.lessonAssistantsReq}\nAnimal Handlers: ${event.animalHandlers.length} / ${event.animalHandlersReq}` } startAccessor="start" endAccessor="end" @@ -362,8 +362,8 @@ function RegistrationDialogue({ selectedEventId, events }: RegistrationProps) { const userIsAdmin = user.roles.find(role => role.name === 'admin') const userIsLessonAssistant = user.roles.find(role => role.name === 'lessonAssistant') != undefined - const userIsHorseLeader = - user.roles.find(role => role.name === 'horseLeader') != undefined + const userIsAnimalHandler = + user.roles.find(role => role.name === 'animalHandler') != undefined const isSubmitting = registrationFetcher.state === 'submitting' @@ -392,7 +392,7 @@ function RegistrationDialogue({ selectedEventId, events }: RegistrationProps) { const helpNeeded = calEvent.cleaningCrewReq > calEvent.cleaningCrew.length || calEvent.lessonAssistantsReq > calEvent.lessonAssistants.length || - calEvent.horseLeadersReq > calEvent.horseLeaders.length || + calEvent.animalHandlersReq > calEvent.animalHandlers.length || calEvent.sideWalkersReq > calEvent.sideWalkers.length const now = new Date() @@ -463,8 +463,8 @@ function RegistrationDialogue({ selectedEventId, events }: RegistrationProps) { let hasPermissions = true if (volunteerType.field == 'lessonAssistants') { hasPermissions = userIsLessonAssistant - } else if (volunteerType.field == 'horseLeaders') { - hasPermissions = userIsHorseLeader + } else if (volunteerType.field == 'animalHandlers') { + hasPermissions = userIsAnimalHandler } return ( @@ -565,11 +565,11 @@ function RegistrationDialogue({ selectedEventId, events }: RegistrationProps) { } interface CreateEventDialogProps { - horses: HorseData[] + animals: AnimalData[] instructors: UserData[] } -function CreateEventDialog({ horses, instructors }: CreateEventDialogProps) { +function CreateEventDialog({ animals, instructors }: CreateEventDialogProps) { const [open, setOpen] = useState(false) return ( @@ -590,7 +590,7 @@ function CreateEventDialog({ horses, instructors }: CreateEventDialogProps) { setOpen(false)} /> @@ -609,7 +609,7 @@ interface EventFormProps extends CreateEventDialogProps { } function CreateEventForm({ - horses, + animals, instructors, doneCallback, }: EventFormProps) { @@ -629,7 +629,7 @@ function CreateEventForm({ lastSubmission: actionData?.submission, defaultValue: { cleaningCrewReq: 0, - horseLeadersReq: 0, + animalHandlersReq: 0, sideWalkersReq: 0, lessonAssistantsReq: 0, }, @@ -648,11 +648,11 @@ function CreateEventForm({ if (doneCallback) { doneCallback() } - } else if (actionData.status === 'horse-error') { + } else if (actionData.status === 'animal-error') { toast({ variant: 'destructive', title: - 'The following horses are scheduled for cooldown on the selected dates:', + 'The following animals are scheduled for cooldown on the selected dates:', description: actionData.message, }) } else { @@ -712,11 +712,11 @@ function CreateEventForm({
- - Animals +
@@ -765,15 +765,15 @@ function CreateEventForm({ role.name === 'horseLeader')) { + if (submission.value.role == 'animalHandlers') { + if (!user.roles.find(role => role.name === 'animalHandler')) { throw json({ error: 'Missing permissions' }, { status: 403 }) } } @@ -234,7 +234,7 @@ async function notifyAdmins({ orgId, }: { event: Event - role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'horseLeaders' + role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'animalHandlers' action: 'register' | 'unregister' user: User orgId: string diff --git a/app/routes/resources+/registration-emails.server.tsx b/app/routes/resources+/registration-emails.server.tsx index 2ab47cf..b355e55 100644 --- a/app/routes/resources+/registration-emails.server.tsx +++ b/app/routes/resources+/registration-emails.server.tsx @@ -9,7 +9,7 @@ export function RegistrationEmail({ role, }: { event: Event - role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'horseLeaders' + role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'animalHandlers' }) { let roleName for (let v of volunteerTypes) { @@ -49,7 +49,7 @@ export function RegistrationNoticeForAdmins({ user, }: { event: Event - role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'horseLeaders', + role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'animalHandlers', action: 'register' | 'unregister'; user: User; }) { diff --git a/app/routes/resources+/unregistration-emails.server.tsx b/app/routes/resources+/unregistration-emails.server.tsx index 18e0ede..34ede74 100644 --- a/app/routes/resources+/unregistration-emails.server.tsx +++ b/app/routes/resources+/unregistration-emails.server.tsx @@ -9,7 +9,7 @@ export function UnregistrationEmail({ role, }: { event: Event - role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'horseLeaders' + role: 'cleaningCrew' | 'lessonAssistants' | 'sideWalkers' | 'animalHandlers' }) { let roleName for (let v of volunteerTypes) { From a8565fbe462015172a2d487fa0f941e1006af928 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:40:34 -0700 Subject: [PATCH 08/26] feat: add organization signup flow and org setup page - Add signupOrg() to auth.server.ts: creates Org + admin User atomically in a single Prisma session transaction, connecting the admin role - Add /org-signup route with two-section form (org details + admin account) including slug generation, duplicate username/email/org validation - Add /org-setup redirect page for authenticated users without an org - Update landing page hero: TrotTrack branding + Register Your Organization CTA alongside existing volunteer signup button Co-Authored-By: George Badulescu --- app/routes/_marketing+/index.tsx | 26 +-- app/routes/_marketing+/org-signup.tsx | 264 ++++++++++++++++++++++++++ app/routes/org-setup.tsx | 29 +++ app/utils/auth.server.ts | 49 +++++ 4 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 app/routes/_marketing+/org-signup.tsx create mode 100644 app/routes/org-setup.tsx diff --git a/app/routes/_marketing+/index.tsx b/app/routes/_marketing+/index.tsx index 6460760..90177a8 100644 --- a/app/routes/_marketing+/index.tsx +++ b/app/routes/_marketing+/index.tsx @@ -49,17 +49,14 @@ export default function Index() {

- - The Barn: Volunteer Portal - + + TrotTrack +

- Equestrian Volunteer Scheduling Application + Volunteer scheduling for animal-assisted therapy nonprofits

-
+
{user ? ( ) : ( - + <> + + + )}
diff --git a/app/routes/_marketing+/org-signup.tsx b/app/routes/_marketing+/org-signup.tsx new file mode 100644 index 0000000..0bff3b0 --- /dev/null +++ b/app/routes/_marketing+/org-signup.tsx @@ -0,0 +1,264 @@ +import { conform, useForm } from '@conform-to/react' +import { getFieldsetConstraint, parse } from '@conform-to/zod' +import { + json, + redirect, + type DataFunctionArgs, + type V2_MetaFunction, +} from '@remix-run/node' +import { + Form, + Link, + useActionData, + useFormAction, + useNavigation, +} from '@remix-run/react' +import { z } from 'zod' +import { ErrorList, Field } from '~/components/forms.tsx' +import { StatusButton } from '~/components/ui/status-button.tsx' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '~/components/ui/select.tsx' +import { Label } from '~/components/ui/label.tsx' +import { Separator } from '~/components/ui/separator.tsx' +import { requireAnonymous, signupOrg } from '~/utils/auth.server.ts' +import { commitSession, getSession } from '~/utils/session.server.ts' +import { authenticator } from '~/utils/auth.server.ts' +import { + nameSchema, + passwordSchema, + usernameSchema, + emailSchema, +} from '~/utils/user-validation.ts' +import { siteName } from '~/data.ts' +import { prisma } from '~/utils/db.server.ts' + +const orgSignupSchema = z + .object({ + orgName: z + .string() + .min(2, { message: 'Organization name is too short' }) + .max(80, { message: 'Organization name is too long' }), + animalType: z.string().min(1, { message: 'Please select an animal type' }), + name: nameSchema, + username: usernameSchema, + email: emailSchema, + password: passwordSchema, + confirmPassword: passwordSchema, + }) + .superRefine(({ confirmPassword, password }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + path: ['confirmPassword'], + code: 'custom', + message: 'The passwords did not match', + }) + } + }) + +function slugify(name: string) { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .slice(0, 60) +} + +export async function loader({ request }: DataFunctionArgs) { + await requireAnonymous(request) + return json({}) +} + +export const meta: V2_MetaFunction = () => { + return [{ title: `Register Your Organization | ${siteName}` }] +} + +export async function action({ request }: DataFunctionArgs) { + await requireAnonymous(request) + const formData = await request.formData() + const submission = await parse(formData, { + schema: orgSignupSchema.superRefine(async ({ username, email, orgName }, ctx) => { + const existingUser = await prisma.user.findFirst({ + where: { OR: [{ username }, { email }] }, + select: { id: true, username: true, email: true }, + }) + if (existingUser?.username === username) { + ctx.addIssue({ + path: ['username'], + code: 'custom', + message: 'A user already exists with this username', + }) + } + if (existingUser?.email === email) { + ctx.addIssue({ + path: ['email'], + code: 'custom', + message: 'A user already exists with this email', + }) + } + const slug = slugify(orgName) + const existingOrg = await prisma.organization.findUnique({ + where: { slug }, + select: { id: true }, + }) + if (existingOrg) { + ctx.addIssue({ + path: ['orgName'], + code: 'custom', + message: 'An organization with a similar name already exists', + }) + } + }), + async: true, + }) + + if (submission.intent !== 'submit') { + return json({ status: 'idle', submission } as const) + } + if (!submission.value) { + return json({ status: 'error', submission } as const, { status: 400 }) + } + + const { orgName, animalType, name, username, email, password } = + submission.value + const orgSlug = slugify(orgName) + + const session = await signupOrg({ + orgName, + orgSlug, + animalType, + email, + username, + name, + password, + }) + + const cookieSession = await getSession(request.headers.get('cookie')) + cookieSession.set(authenticator.sessionKey, session.id) + + return redirect('/calendar', { + headers: { + 'Set-Cookie': await commitSession(cookieSession, { + expires: session.expirationDate, + }), + }, + }) +} + +export default function OrgSignup() { + const actionData = useActionData() + const navigation = useNavigation() + const formAction = useFormAction() + + const isSubmitting = + navigation.state === 'submitting' && navigation.formAction === formAction + + const [form, fields] = useForm({ + id: 'org-signup', + constraint: getFieldsetConstraint(orgSignupSchema), + lastSubmission: actionData?.submission, + shouldRevalidate: 'onBlur', + }) + + return ( +
+
+
+

Register Your Organization

+

+ Create your organization account and start coordinating volunteers. +

+
+ + +
+

Organization Details

+
+ +
+ + + {fields.animalType.errors ? ( + + ) : null} +
+
+
+ + + +
+

Admin Account

+
+ + + + + +
+
+ + + +
+

+ Already have an account?{' '} + + Log in + +

+ + Create Organization + +
+ +
+
+ ) +} diff --git a/app/routes/org-setup.tsx b/app/routes/org-setup.tsx new file mode 100644 index 0000000..2c5896a --- /dev/null +++ b/app/routes/org-setup.tsx @@ -0,0 +1,29 @@ +import { type DataFunctionArgs } from '@remix-run/node' +import { Link } from '@remix-run/react' +import { requireUserId } from '~/utils/auth.server.ts' +import { Button } from '~/components/ui/button.tsx' + +export async function loader({ request }: DataFunctionArgs) { + await requireUserId(request) + return null +} + +export default function OrgSetup() { + return ( +
+

No Organization Found

+

+ Your account is not associated with any organization. Ask your + organization admin to add you, or register a new organization. +

+
+ + +
+
+ ) +} diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index d016f34..0417998 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -198,6 +198,55 @@ export async function verifyLogin( return { id: userWithPassword.id } } +export async function signupOrg({ + orgName, + orgSlug, + animalType, + email, + username, + name, + password, +}: { + orgName: string + orgSlug: string + animalType: string + email: string + username: string + name: string + password: string +}) { + const hashedPassword = await getPasswordHash(password) + + const adminRole = await prisma.role.findFirst({ where: { name: 'admin' } }) + + const session = await prisma.session.create({ + data: { + expirationDate: new Date(Date.now() + SESSION_EXPIRATION_TIME), + user: { + create: { + email, + username, + name, + password: { + create: { hash: hashedPassword }, + }, + roles: adminRole ? { connect: { id: adminRole.id } } : undefined, + org: { + create: { + name: orgName, + slug: orgSlug, + animalType, + }, + }, + }, + }, + }, + select: { id: true, expirationDate: true, userId: true }, + }) + + return session +} + export async function verifySignupPassword(password: string) { const signUpPassword = await prisma.signupPassword.findFirst(); From d15ffcd4d54810cbcd1023f9b30f71c577cda4c8 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:52:04 -0700 Subject: [PATCH 09/26] feat: scope remaining admin and calendar routes by orgId - users.delete: add requireOrgMember, use findFirst with orgId - users.edit: add requireOrgMember, fix isHorseLeader -> isAnimalHandler - users.promote: add requireOrgMember, verify userId belongs to org before update - $eventId.delete: add requireOrgMember, scope event lookup by orgId - email.tsx: scope getRecipientsFromRoles and getUpcomingEvents by orgId ensuring bulk emails only go to users in the same organization --- app/routes/admin+/_email+/email.tsx | 17 +++++++------ .../admin+/_users+/users.delete.$userId.tsx | 7 ++++-- .../admin+/_users+/users.edit.$userId.tsx | 25 +++++++++++-------- .../admin+/_users+/users.promote.$userId.tsx | 10 ++++++-- app/routes/calendar+/$eventId.delete.tsx | 7 ++++-- 5 files changed, 42 insertions(+), 24 deletions(-) diff --git a/app/routes/admin+/_email+/email.tsx b/app/routes/admin+/_email+/email.tsx index 16c98f7..ff67304 100644 --- a/app/routes/admin+/_email+/email.tsx +++ b/app/routes/admin+/_email+/email.tsx @@ -8,6 +8,7 @@ import { } from '@remix-run/react' import { z } from 'zod' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { useToast } from '~/components/ui/use-toast.ts' import { checkboxSchema } from '~/utils/zod-extensions.ts' import { useResetCallback } from '~/utils/misc.ts' @@ -49,6 +50,7 @@ export const loader = async ({ request }: LoaderArgs) => { export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) const formData = await request.formData() const submission = parse(formData, { schema: emailFormSchema }) if (!submission.value) { @@ -64,8 +66,8 @@ export async function action({ request, params }: DataFunctionArgs) { 'instructor', ] const selectedRoles = roles.filter(role => submission.payload[role] === 'on') - const recipients = await getRecipientsFromRoles(selectedRoles) - const upcomingEvents = await getUpcomingEvents(5); + const recipients = await getRecipientsFromRoles(selectedRoles, orgId) + const upcomingEvents = await getUpcomingEvents(5, orgId); if (recipients.length === 0) { return json( @@ -242,9 +244,10 @@ export default function Email() { ) } -async function getUpcomingEvents(limit: number) { +async function getUpcomingEvents(limit: number, orgId: string) { const events = await prisma.event.findMany({ where: { + orgId, start: { gt: new Date() }, // Don't include private events in upcoming events isPrivate: false, @@ -255,10 +258,10 @@ async function getUpcomingEvents(limit: number) { } -async function getRecipientsFromRoles(roles: string[]) { +async function getRecipientsFromRoles(roles: string[], orgId: string) { const recipients = new Set() if (roles.includes('allVolunteers')) { - const users = await prisma.user.findMany() + const users = await prisma.user.findMany({ where: { orgId } }) users .filter(user => user.mailingList) .map(user => user.email) @@ -266,7 +269,7 @@ async function getRecipientsFromRoles(roles: string[]) { } else { for (let role of roles) { const users = await prisma.user.findMany({ - where: { roles: { some: { name: role } } }, + where: { orgId, roles: { some: { name: role } } }, }) users .filter(user => user.mailingList) @@ -275,7 +278,7 @@ async function getRecipientsFromRoles(roles: string[]) { // Include admin on all emails const admin = await prisma.user.findMany({ - where: { roles: { some: { name: 'admin' } } }, + where: { orgId, roles: { some: { name: 'admin' } } }, }) admin .filter(user => user.mailingList) diff --git a/app/routes/admin+/_users+/users.delete.$userId.tsx b/app/routes/admin+/_users+/users.delete.$userId.tsx index f939270..e554c70 100644 --- a/app/routes/admin+/_users+/users.delete.$userId.tsx +++ b/app/routes/admin+/_users+/users.delete.$userId.tsx @@ -20,6 +20,7 @@ import { } from '@remix-run/react' import { json, type DataFunctionArgs } from '@remix-run/node' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' import invariant from 'tiny-invariant' import { conform, useForm } from '@conform-to/react' @@ -31,8 +32,9 @@ import { z } from 'zod' export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') - const user = await prisma.user.findUnique({ where: { id: params.userId } }) + const user = await prisma.user.findFirst({ where: { id: params.userId, orgId } }) if (!user) { throw new Response('not found', { status: 404 }) } @@ -50,9 +52,10 @@ export const deleteUserFormSchema = z.object({ export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') const formData = await request.formData() - const user = await prisma.user.findUnique({ where: { id: params.userId } }) + const user = await prisma.user.findFirst({ where: { id: params.userId, orgId } }) if (!user) { throw new Response('not found', { status: 404 }) } diff --git a/app/routes/admin+/_users+/users.edit.$userId.tsx b/app/routes/admin+/_users+/users.edit.$userId.tsx index 7bd5a82..1b32b3f 100644 --- a/app/routes/admin+/_users+/users.edit.$userId.tsx +++ b/app/routes/admin+/_users+/users.edit.$userId.tsx @@ -26,6 +26,7 @@ import { } from '@remix-run/react' import { json, type DataFunctionArgs } from '@remix-run/node' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' import invariant from 'tiny-invariant' import { conform, useFieldset, useForm } from '@conform-to/react' @@ -58,15 +59,16 @@ const editUserSchema = z.object({ yearsOfExperience: yearsOfExperienceSchema, isInstructor: checkboxSchema(), isLessonAssistant: checkboxSchema(), - isHorseLeader: checkboxSchema(), + isAnimalHandler: checkboxSchema(), notes: z.string().optional(), }) export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') - const user = await prisma.user.findUnique({ - where: { id: params.userId }, + const user = await prisma.user.findFirst({ + where: { id: params.userId, orgId }, include: { roles: true }, }) if (!user) { @@ -77,6 +79,7 @@ export const loader = async ({ request, params }: DataFunctionArgs) => { export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') const formData = await request.formData() const submission = await parse(formData, { @@ -106,7 +109,7 @@ export async function action({ request, params }: DataFunctionArgs) { height, yearsOfExperience, isInstructor, - isHorseLeader, + isAnimalHandler, isLessonAssistant, notes, } = submission.value @@ -118,7 +121,7 @@ export async function action({ request, params }: DataFunctionArgs) { } else { roleDisconnectArray.push({ name: 'instructor' }) } - if (isHorseLeader) { + if (isAnimalHandler) { roleConnectArray.push({ name: 'animalHandler' }) } else { roleDisconnectArray.push({ name: 'animalHandler' }) @@ -213,14 +216,14 @@ export default function EditUser() { const { heightFeet, heightInches } = useFieldset(form.ref, fields.height) let isLessonAssistant = false - let isHorseLeader = false + let isAnimalHandler = false let isInstructor = false for (const role of data.user?.roles) { if (role.name === 'lessonAssistant') { isLessonAssistant = true } if (role.name === 'animalHandler') { - isHorseLeader = true + isAnimalHandler = true } if (role.name === 'instructor') { isInstructor = true @@ -413,16 +416,16 @@ export default function EditUser() { />
diff --git a/app/routes/admin+/_users+/users.promote.$userId.tsx b/app/routes/admin+/_users+/users.promote.$userId.tsx index 77a5e56..1ce07d0 100644 --- a/app/routes/admin+/_users+/users.promote.$userId.tsx +++ b/app/routes/admin+/_users+/users.promote.$userId.tsx @@ -18,6 +18,7 @@ import { useNavigation, } from '@remix-run/react' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' import { StatusButton } from '~/components/ui/status-button.tsx' import { Button } from '~/components/ui/button.tsx' @@ -30,9 +31,10 @@ import { redirectWithToast } from '~/utils/flash-session.server.ts' export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') - const user = await prisma.user.findUnique({ - where: { id: params.userId }, + const user = await prisma.user.findFirst({ + where: { id: params.userId, orgId }, include: { roles: true, }, @@ -49,6 +51,7 @@ const promoteSchema = z.object({ export const action = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.userId, 'Missing user id') const formData = await request.formData() const submission = parse(formData, { schema: promoteSchema }) @@ -68,6 +71,9 @@ export const action = async ({ request, params }: DataFunctionArgs) => { }) } + const targetUser = await prisma.user.findFirst({ where: { id: params.userId, orgId } }) + if (!targetUser) throw new Response('not found', { status: 404 }) + let user if (submission.value._action === 'promote') { user = await prisma.user.update({ diff --git a/app/routes/calendar+/$eventId.delete.tsx b/app/routes/calendar+/$eventId.delete.tsx index 899e592..3556ad4 100644 --- a/app/routes/calendar+/$eventId.delete.tsx +++ b/app/routes/calendar+/$eventId.delete.tsx @@ -20,6 +20,7 @@ import { } from '@remix-run/react' import { json, type DataFunctionArgs } from '@remix-run/node' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { prisma } from '~/utils/db.server.ts' import invariant from 'tiny-invariant' import { conform, useForm } from '@conform-to/react' @@ -31,8 +32,9 @@ import { z } from 'zod' export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.eventId, 'Missing event id') - const event = await prisma.event.findUnique({ where: { id: params.eventId } }) + const event = await prisma.event.findFirst({ where: { id: params.eventId, orgId } }) if (!event) { throw new Response('not found', { status: 404 }) } @@ -50,9 +52,10 @@ export const deleteEventFormSchema = z.object({ export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.eventId, 'Missing event id') const formData = await request.formData() - const event = await prisma.event.findUnique({ where: { id: params.eventId } }) + const event = await prisma.event.findFirst({ where: { id: params.eventId, orgId } }) if (!event) { throw new Response('not found', { status: 404 }) } From 52b62667bfe13a34d9f2531e5e1fd6f9320217bb Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:56:00 -0700 Subject: [PATCH 10/26] feat: scope event edit route by orgId and add super admin orgs page - $eventId.edit: add requireOrgMember, scope instructors/animals/event queries by orgId; verify event ownership before update - super-admin/orgs: new page listing all organizations with user/animal/ event counts, protected by requireSuperAdmin --- app/routes/calendar+/$eventId.edit.tsx | 14 +++-- app/routes/super-admin+/orgs.tsx | 76 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 app/routes/super-admin+/orgs.tsx diff --git a/app/routes/calendar+/$eventId.edit.tsx b/app/routes/calendar+/$eventId.edit.tsx index 28087b3..db64414 100644 --- a/app/routes/calendar+/$eventId.edit.tsx +++ b/app/routes/calendar+/$eventId.edit.tsx @@ -23,6 +23,7 @@ import { import { Icon } from '~/components/ui/icon.tsx' import { prisma } from '~/utils/db.server.ts' import { requireAdmin } from '~/utils/permissions.server.ts' +import { requireOrgMember } from '~/utils/auth.server.ts' import { AnimalListbox, InstructorListbox } from '~/components/listboxes.tsx' import { addMinutes, differenceInMinutes, format, add } from 'date-fns' import { redirectWithToast } from '~/utils/flash-session.server.ts' @@ -44,14 +45,15 @@ import { useResetCallback } from '~/utils/misc.ts' export const loader = async ({ request, params }: DataFunctionArgs) => { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.eventId, 'Missing event id') const instructors = await prisma.user.findMany({ - where: { roles: { some: { name: 'instructor' } } }, + where: { orgId, roles: { some: { name: 'instructor' } } }, }) - const animals = await prisma.animal.findMany() - const event = await prisma.event.findUnique({ - where: { id: params.eventId }, + const animals = await prisma.animal.findMany({ where: { orgId } }) + const event = await prisma.event.findFirst({ + where: { id: params.eventId, orgId }, include: { animals: true, instructors: true, @@ -94,6 +96,7 @@ const editEventSchema = z.object({ export async function action({ request, params }: DataFunctionArgs) { await requireAdmin(request) + const { orgId } = await requireOrgMember(request) invariant(params.eventId, 'Missing event id') const formData = await request.formData() const submission = parse(formData, { @@ -153,6 +156,9 @@ export async function action({ request, params }: DataFunctionArgs) { const isPrivate = submission.value.isPrivate + const existingEvent = await prisma.event.findFirst({ where: { id: params.eventId, orgId } }) + if (!existingEvent) throw new Response('not found', { status: 404 }) + const updatedEvent = await prisma.event.update({ where: { id: params.eventId, diff --git a/app/routes/super-admin+/orgs.tsx b/app/routes/super-admin+/orgs.tsx new file mode 100644 index 0000000..4aeb3a4 --- /dev/null +++ b/app/routes/super-admin+/orgs.tsx @@ -0,0 +1,76 @@ +import { json, type DataFunctionArgs } from '@remix-run/node' +import { useLoaderData, Link } from '@remix-run/react' +import { requireSuperAdmin } from '~/utils/permissions.server.ts' +import { prisma } from '~/utils/db.server.ts' +import { format } from 'date-fns' + +export const loader = async ({ request }: DataFunctionArgs) => { + await requireSuperAdmin(request) + const orgs = await prisma.organization.findMany({ + include: { + _count: { select: { users: true, animals: true, events: true } }, + }, + orderBy: { createdAt: 'desc' }, + }) + return json({ orgs }) +} + +export default function SuperAdminOrgs() { + const { orgs } = useLoaderData() + + return ( +
+

All Organizations

+
+ + + + + + + + + + + + + + + {orgs.map(org => ( + + + + + + + + + + + ))} + +
NameSlugAnimal TypeUsersAnimalsEventsStatusCreated
{org.name}{org.slug}{org.animalType}{org._count.users}{org._count.animals}{org._count.events} + + {org.isActive ? 'Active' : 'Inactive'} + + + {format(new Date(org.createdAt), 'MMM d, yyyy')} +
+ {orgs.length === 0 && ( +
+ No organizations found. +
+ )} +
+

+ {orgs.length} organization{orgs.length !== 1 ? 's' : ''} total +

+
+ ) +} From 597e2cd2e8e14068cffcf4feb701b95deb04d1cc Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:15:16 -0700 Subject: [PATCH 11/26] style: update color tokens and meta for multi-tenant platform --- app/root.tsx | 4 ++-- app/styles/tailwind.css | 12 ++++++++++++ tailwind.config.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/root.tsx b/app/root.tsx index 838a188..9462927 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -77,8 +77,8 @@ export const links: LinksFunction = () => { export const meta: V2_MetaFunction = () => { return [ - { title: 'The Barn - Volunteer Portal' }, - { name: 'description', content: 'Equestrian Volunteer Coordinator' }, + { title: 'TrotTrack' }, + { name: 'description', content: 'Volunteer scheduling for animal-assisted therapy nonprofits' }, ] } diff --git a/app/styles/tailwind.css b/app/styles/tailwind.css index 615def7..a170c8b 100644 --- a/app/styles/tailwind.css +++ b/app/styles/tailwind.css @@ -38,6 +38,12 @@ --radius: 0.5rem; + --color-sidebar: 240 5% 96%; + --color-sidebar-foreground: 240 5.9% 10%; + --color-sidebar-border: 240 5.9% 90%; + --color-sidebar-active: 239 84.2% 67.1%; + --color-sidebar-active-foreground: 0 0% 100%; + --color-brand-primary: 217 91% 60%; --color-brand-primary-muted: 208 56% 44%; --color-brand-secondary: 40 100% 62%; @@ -93,6 +99,12 @@ --color-destructive-foreground: 0 85.7% 97.3%; --color-ring: 217 32.6% 17.5%; + + --color-sidebar: 240 5.9% 10%; + --color-sidebar-foreground: 210 40% 98%; + --color-sidebar-border: 240 3.7% 15.9%; + --color-sidebar-active: 239 84.2% 67.1%; + --color-sidebar-active-foreground: 0 0% 100%; } } diff --git a/tailwind.config.ts b/tailwind.config.ts index 7a73e4a..118a4c7 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -74,7 +74,14 @@ export default { DEFAULT: 'hsl(var(--color-card))', foreground: 'hsl(var(--color-card-foreground))', }, - day: { + 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: { 100: 'hsl(var(--color-day-100))', 200: 'hsl(var(--color-day-200))', 300: 'hsl(var(--color-day-300))', From 8b8dd2ec46873c2090f692890b56720cbc9f0226 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:16:46 -0700 Subject: [PATCH 12/26] style: redesign sidebar navigation layout and app shell --- app/components/sidebar.tsx | 97 +++++++++++++++++ app/root.tsx | 212 ++++++++----------------------------- 2 files changed, 139 insertions(+), 170 deletions(-) create mode 100644 app/components/sidebar.tsx diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx new file mode 100644 index 0000000..bd98f47 --- /dev/null +++ b/app/components/sidebar.tsx @@ -0,0 +1,97 @@ +import { useRef } from 'react' +import { Form, Link, NavLink } from '@remix-run/react' +import { Icon } from '~/components/ui/icon.tsx' +import { ThemeSwitch } from '~/routes/resources+/theme/index.tsx' +import { useUser } from '~/utils/user.ts' +import { getUserImgSrc } from '~/utils/misc.ts' + +export function Sidebar({ + userPreference, +}: { + userPreference: 'light' | 'dark' | null +}) { + const user = useUser() + const formRef = useRef(null) + const userIsAdmin = user?.roles.find(r => r.name === 'admin') + + const navLinkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 rounded-md px-3 py-2 text-body-sm font-medium transition-colors ${ + isActive + ? 'bg-sidebar-active text-sidebar-active-foreground' + : 'text-sidebar-foreground hover:bg-sidebar-border' + }` + + return ( + + ) +} diff --git a/app/root.tsx b/app/root.tsx index 9462927..3e31411 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,10 +1,3 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuPortal, - DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu.tsx' import { cssBundleHref } from '@remix-run/css-bundle' import { json, @@ -14,7 +7,6 @@ import { type V2_MetaFunction, } from '@remix-run/node' import { - Form, Link, Links, LiveReload, @@ -23,7 +15,6 @@ import { Scripts, ScrollRestoration, useLoaderData, - useSubmit, } from '@remix-run/react' import { withSentry } from '@sentry/remix' import { ThemeSwitch, useTheme } from './routes/resources+/theme/index.tsx' @@ -35,16 +26,16 @@ import { ClientHintCheck, getHints } from './utils/client-hints.tsx' import { prisma } from './utils/db.server.ts' import { getEnv } from './utils/env.server.ts' import { Button } from '~/components/ui/button.tsx' -import { combineHeaders, getDomainUrl, getUserImgSrc } from './utils/misc.ts' +import { combineHeaders, getDomainUrl } from './utils/misc.ts' import { useNonce } from './utils/nonce-provider.ts' import { makeTimings, time } from './utils/timing.server.ts' -import { useOptionalUser, useUser } from './utils/user.ts' -import { useRef } from 'react' -import { Icon, href as iconsHref } from './components/ui/icon.tsx' +import { useOptionalUser } from './utils/user.ts' +import { href as iconsHref } from './components/ui/icon.tsx' import { Confetti } from './components/confetti.tsx' import { getFlashSession } from './utils/flash-session.server.ts' import { useToast } from './utils/useToast.tsx' import { Toaster } from './components/ui/toaster.tsx' +import { Sidebar } from './components/sidebar.tsx' export const links: LinksFunction = () => { return [ @@ -151,32 +142,6 @@ function App() { const theme = useTheme() useToast(data.flash?.toast) - const userIsAdmin = user?.roles.find(role => role.name === 'admin') - - let nav = ( - - ) - if (user) { - nav = ( -
-
- - {userIsAdmin ? : null} -
-
- -
-
- ) - } - return ( @@ -186,39 +151,45 @@ function App() { - -
- -
- -
- -
- -
- -
Equestrian
-
Volunteer Scheduler
- -
- - Terms of Service - - - Privacy Policy - + + {user ? ( + // Authenticated: sidebar layout +
+ +
+ +
- - -
- -
+ ) : ( + // Unauthenticated: top-nav layout +
+
+
+ + TrotTrack + +
+ + +
+
+
+
+ +
+
+
+ TrotTrack +
+ Terms of Service + Privacy Policy +
+
+
+
+ )} @@ -235,102 +206,3 @@ function App() { ) } export default withSentry(App) - -function UserDropdown() { - const user = useUser() - const submit = useSubmit() - const formRef = useRef(null) - return ( - - - - - - - - - - Profile - - - - { - event.preventDefault() - submit(formRef.current) - }} - > -
- -
-
-
-
-
- ) -} - -function AdminDropdown() { - return ( - - - - - - - - - - Users - - - - - - - Animals - - - - - - - Email - - - - - - - ) -} From 91d56cc582b3c3ad8642f76b91cce17eea6a4043 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:17:54 -0700 Subject: [PATCH 13/26] style: redesign public landing page hero and features section --- app/routes/_marketing+/index.tsx | 185 ++++++++++++++++--------------- 1 file changed, 96 insertions(+), 89 deletions(-) diff --git a/app/routes/_marketing+/index.tsx b/app/routes/_marketing+/index.tsx index 90177a8..fac9a6f 100644 --- a/app/routes/_marketing+/index.tsx +++ b/app/routes/_marketing+/index.tsx @@ -1,109 +1,116 @@ import type { V2_MetaFunction } from '@remix-run/node' -import { horseMountains, ohack } from './logos/logos.ts' import { Link } from '@remix-run/react' import { Button } from '~/components/ui/button.tsx' import { useOptionalUser } from '~/utils/user.ts' -// Get siteName from data.ts import { siteName } from '~/data.ts' - -export const meta: V2_MetaFunction = () => { - +export const meta: V2_MetaFunction = () => { return [ - { - title: siteName, - }, - { - property: "og:title", - content: siteName, - }, - { - name: "description", - content: `Welcome to ${siteName}, the premier equestrian training facility.`, - }, - { - name: "og:description", - content: `Welcome to ${siteName}, the premier equestrian training facility.`, - }, + { title: `${siteName} โ€” Volunteer Scheduling for Nonprofits` }, { - name: "og:image", - content: "/img/calendar-icon-with-horse-at-grand-canyon-using-arizona-flag-colors.jpeg", + name: 'description', + content: + 'TrotTrack helps animal-assisted therapy nonprofits coordinate volunteers, manage animals, and schedule events โ€” all in one place.', }, - ]; -}; + ] +} + +const features = [ + { + icon: '๐Ÿ“…', + title: 'Event Scheduling', + description: + 'Create and manage therapy sessions, assign animals, and set volunteer slots with a visual calendar.', + }, + { + icon: '๐Ÿ™‹', + title: 'Volunteer Management', + description: + 'Volunteers self-register for roles. Admins see who is coming, assign handlers, and send bulk emails.', + }, + { + icon: '๐Ÿพ', + title: 'Animal Tracking', + description: + 'Track your animals, manage cooldown periods between events, and prevent scheduling conflicts automatically.', + }, + { + icon: '๐Ÿข', + title: 'Multi-Org Support', + description: + 'Each organization gets its own isolated workspace. Data never crosses between organizations.', + }, +] export default function Index() { const user = useOptionalUser() + return ( -
-
-
-
-
- -
-
-
-

- - TrotTrack - -

-

- Volunteer scheduling for animal-assisted therapy nonprofits -

-
- {user ? ( - - ) : ( - <> - - - - )} -
-
-
+
+ {/* Hero */} +
+ + Animal-Assisted Therapy Nonprofits + +

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

+

+ TrotTrack gives your organization a dedicated space to manage events, + track animals, and keep volunteers in sync โ€” with zero spreadsheets. +

+
+ {user ? ( + + ) : ( + <> + + + + )}
+
-
-
-
- - Trot Track - {' '} - is built by: - +
+

+ Everything your org needs +

+
+ {features.map(f => ( + +
{f.icon}
+

{f.title}

+

{f.description}

+
+ ))}
-
-
+ + + {/* CTA strip */} +
+

Ready to get started?

+

+ Set up your organization in minutes. No credit card required. +

+ {!user && ( + + )} +
+
) } - From 6604316d58f910761101380c42448126d6d39197 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:19:21 -0700 Subject: [PATCH 14/26] style: update signup and login pages to two-column layout --- app/routes/_auth+/login.tsx | 44 +++++++++++++++++++-------- app/routes/_marketing+/org-signup.tsx | 27 +++++++++++++--- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/routes/_auth+/login.tsx b/app/routes/_auth+/login.tsx index 2221863..10b7c1e 100644 --- a/app/routes/_auth+/login.tsx +++ b/app/routes/_auth+/login.tsx @@ -41,20 +41,38 @@ export default function LoginPage() { const redirectTo = searchParams.get('redirectTo') || '/' return ( -
-
-
-

Welcome back!

-

- Please enter your details. -

+
+ {/* Left brand panel */} +
+
TrotTrack
+
+
+ "Coordinating our therapy horse sessions used to take hours of emails. + Now it takes minutes." +
+

โ€” Program Coordinator, Tumbling T Ranch

+
+

+ Volunteer scheduling for animal-assisted therapy nonprofits. +

+
+ + {/* Right form panel */} +
+
+
+

Welcome back

+

+ Sign in to your account to continue. +

+
+ + {data.unverified ? ( + + ) : ( + + )}
- - {data.unverified ? ( - - ) : ( - - )}
) diff --git a/app/routes/_marketing+/org-signup.tsx b/app/routes/_marketing+/org-signup.tsx index 0bff3b0..1a44b98 100644 --- a/app/routes/_marketing+/org-signup.tsx +++ b/app/routes/_marketing+/org-signup.tsx @@ -167,11 +167,29 @@ export default function OrgSignup() { }) return ( -
+
+ {/* Left brand panel */} +
+
TrotTrack
+
+

Get started in minutes.

+
    +
  • โœ“ Create your organization and first admin account
  • +
  • โœ“ Invite volunteers and assign roles
  • +
  • โœ“ Schedule events and track animals
  • +
+
+

+ Volunteer scheduling for animal-assisted therapy nonprofits. +

+
+ + {/* Right form panel */} +
-
-

Register Your Organization

-

+

+

Register Your Organization

+

Create your organization account and start coordinating volunteers.

@@ -259,6 +277,7 @@ export default function OrgSignup() {
+
) } From 03990d2e06ed8db0a5464ecdb680bdb4f44589d4 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:21:51 -0700 Subject: [PATCH 15/26] style: redesign volunteer table with filter bar and improved header --- app/routes/admin+/_users+/users.tsx | 49 ++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/app/routes/admin+/_users+/users.tsx b/app/routes/admin+/_users+/users.tsx index 546e8e2..9c968b3 100644 --- a/app/routes/admin+/_users+/users.tsx +++ b/app/routes/admin+/_users+/users.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { type LoaderArgs, json, useLoaderData, Outlet, Link } from '~/remix.ts' import { prisma } from '~/utils/db.server.ts' import { requireAdmin } from '~/utils/permissions.server.ts' @@ -28,12 +29,50 @@ export const loader = async ({ request }: LoaderArgs) => { export default function Users() { const data = useLoaderData() + const [search, setSearch] = useState('') + + const filtered = data.filter(u => { + const q = search.toLowerCase() + return ( + !q || + u.name?.toLowerCase().includes(q) || + u.email.toLowerCase().includes(q) || + u.username.toLowerCase().includes(q) + ) + }) + return ( -
-

Users

-
- - +
+
+
+

Volunteers

+

+ {data.length} member{data.length !== 1 ? 's' : ''} in your organization +

+
+
+ + {/* Filter bar */} +
+
+ setSearch(e.target.value)} + className="w-full rounded-md border border-input bg-background px-3 py-2 text-body-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> +
+ {search && ( + + {filtered.length} result{filtered.length !== 1 ? 's' : ''} + + )} +
+ + +
+
From ddffd3e0689f507dc0e0477a2a93171945a9cf99 Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Thu, 12 Mar 2026 22:22:48 -0700 Subject: [PATCH 16/26] style: redesign dashboard stat cards and calendar layout --- app/routes/calendar+/index.tsx | 66 ++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/app/routes/calendar+/index.tsx b/app/routes/calendar+/index.tsx index 2313254..4e8a1e3 100644 --- a/app/routes/calendar+/index.tsx +++ b/app/routes/calendar+/index.tsx @@ -300,25 +300,54 @@ export default function Schedule() { [], ) + const upcomingCount = events.filter(e => e.start.valueOf() > new Date().valueOf()).length + const needsHelpCount = eventsThatNeedHelp.length + return ( -
-

Calendar

-
- setFilterFlag(!filterFlag)} - id="filter" - /> - +
+ {/* Page header */} +
+
+

Calendar

+

+ Schedule events and manage volunteer assignments +

+
+ {userIsAdmin ? ( + + ) : null}
- {userIsAdmin ? ( - - ) : null} + {/* Stat cards */} +
+
+

+ Upcoming Events +

+

{upcomingCount}

+
+
+

+ Need Volunteers +

+

0 ? 'text-amber-600' : 'text-green-600'}`}> + {needsHelpCount} +

+
+
+ setFilterFlag(!filterFlag)} + id="filter" + /> + +
+
-
+ {/* Calendar */} +
Date: Thu, 12 Mar 2026 22:24:18 -0700 Subject: [PATCH 17/26] style: add empty states to data views and improve animals page layout --- app/components/ui/data_table.tsx | 21 ++++++++++++++++----- app/routes/admin+/_animals+/animals.tsx | 22 +++++++++++++++------- app/routes/admin+/_users+/users.tsx | 7 ++++++- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/app/components/ui/data_table.tsx b/app/components/ui/data_table.tsx index 7b97dc6..d304d4c 100644 --- a/app/components/ui/data_table.tsx +++ b/app/components/ui/data_table.tsx @@ -20,11 +20,15 @@ import { Button } from '~/components/ui/button.tsx' interface DataTableProps { columns: ColumnDef[] data: TData[] + emptyMessage?: string + emptyDescription?: string } export function DataTable({ columns, data, + emptyMessage = 'No results found', + emptyDescription, }: DataTableProps) { const table = useReactTable({ data, @@ -80,11 +84,18 @@ export function DataTable({ )) ) : ( - - No results. + +
+
โ€”
+

+ {emptyMessage} +

+ {emptyDescription && ( +

+ {emptyDescription} +

+ )} +
)} diff --git a/app/routes/admin+/_animals+/animals.tsx b/app/routes/admin+/_animals+/animals.tsx index 8e64016..8435524 100644 --- a/app/routes/admin+/_animals+/animals.tsx +++ b/app/routes/admin+/_animals+/animals.tsx @@ -96,14 +96,22 @@ export const loader = async ({ request }: DataFunctionArgs) => { export default function Animals() { const data = useLoaderData() return ( -
-

Animals

-
+
+
+
+

Animals

+

+ {data.length} animal{data.length !== 1 ? 's' : ''} in your roster +

+
-
- -
+
) @@ -165,7 +173,7 @@ function CreateAnimalDialog() { return ( - diff --git a/app/routes/admin+/_users+/users.tsx b/app/routes/admin+/_users+/users.tsx index 9c968b3..a4967f9 100644 --- a/app/routes/admin+/_users+/users.tsx +++ b/app/routes/admin+/_users+/users.tsx @@ -70,7 +70,12 @@ export default function Users() { )}
- +
From 0aa1ac8c825c82ee1c4f90e7cd388d443f3e7e6a Mon Sep 17 00:00:00 2001 From: George Badulescu <110639301+gbchill@users.noreply.github.com> Date: Sat, 14 Mar 2026 10:04:37 -0700 Subject: [PATCH 18/26] Update UI/UX to fix ui things --- app/components/sidebar.tsx | 2 +- app/data.ts | 8 ++++---- app/root.tsx | 10 +++++----- app/routes/_auth+/login.tsx | 2 +- app/routes/_marketing+/index.tsx | 8 ++++---- app/routes/_marketing+/org-signup.tsx | 2 +- app/routes/resources+/theme/index.tsx | 12 +++--------- 7 files changed, 19 insertions(+), 25 deletions(-) diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index bd98f47..081d1cd 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -26,7 +26,7 @@ export function Sidebar({ {/* Logo */}
- TrotTrack + The Barn
diff --git a/app/data.ts b/app/data.ts index ac9da9a..67f238d 100644 --- a/app/data.ts +++ b/app/data.ts @@ -1,10 +1,10 @@ import { Prisma } from '@prisma/client' -export const siteName = 'TrotTrack Volunteer Portal' -export const siteEmailAddress = 'hello@email.trottrack.org' +export const siteName = 'The Barn Volunteer Portal' +export const siteEmailAddress = 'hello@thebarnaz.com' export const siteEmailAddressWithName = - siteName + ' ' -export const siteBaseUrl = 'https://trottrack.org' + siteName + ' ' +export const siteBaseUrl = 'https://thebarnaz.com' export const volunteerTypes = [ { diff --git a/app/root.tsx b/app/root.tsx index 3e31411..ddd8bf9 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -68,7 +68,7 @@ export const links: LinksFunction = () => { export const meta: V2_MetaFunction = () => { return [ - { title: 'TrotTrack' }, + { title: 'The Barn' }, { name: 'description', content: 'Volunteer scheduling for animal-assisted therapy nonprofits' }, ] } @@ -164,9 +164,9 @@ function App() { // Unauthenticated: top-nav layout
-
- - TrotTrack +
+ + The Barn
@@ -181,7 +181,7 @@ function App() {