Merge main into fix/add-cd-job-gro-178

This commit is contained in:
groombook-engineer[bot]
2026-03-28 22:19:48 +00:00
committed by GitHub
21 changed files with 581 additions and 61 deletions
@@ -17,6 +17,7 @@ const MANAGER: StaffRow = {
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
+1
View File
@@ -9,6 +9,7 @@ const MANAGER: StaffRow = {
oidcSub: "oidc-manager-sub",
userId: null,
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
+1
View File
@@ -10,6 +10,7 @@ const MANAGER: StaffRow = {
oidcSub: "oidc-manager-sub",
userId: "ba-user-manager",
role: "manager",
isSuperUser: true,
name: "Manager McManager",
email: "manager@example.com",
active: true,
+6 -9
View File
@@ -2,7 +2,6 @@ import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth.js";
import { clientsRouter } from "./routes/clients.js";
import { petsRouter } from "./routes/pets.js";
@@ -68,19 +67,17 @@ app.get("/api/branding", async (c) => {
// Public iCal calendar feed — token auth in URL, no auth middleware required
app.route("/api/calendar", calendarRouter);
// Better-Auth handler — public, handles OAuth callbacks, session management
// Mounted BEFORE auth middleware so it's accessible without authentication
app.on(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], "/api/auth/**", (c) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { incoming, outgoing } = c.env as any;
return toNodeHandler(auth)(incoming, outgoing);
});
// Protected API routes
const api = app.basePath("/api");
api.use("*", authMiddleware);
api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => auth.handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
// Manager-only: admin settings, reports, invoices, impersonation
// Staff CRUD: all roles may READ; manager-only for CREATE/UPDATE/DELETE
+16 -3
View File
@@ -4,6 +4,7 @@ import { genericOAuth } from "better-auth/plugins";
import { getDb } from "@groombook/db";
const OIDC_ISSUER = process.env.OIDC_ISSUER;
const OIDC_INTERNAL_BASE = process.env.OIDC_INTERNAL_BASE; // e.g. http://authentik-server.auth.svc.cluster.local
const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID;
const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
@@ -28,9 +29,21 @@ export const auth = betterAuth({
providerId: "authentik",
clientId: OIDC_CLIENT_ID ?? "",
clientSecret: OIDC_CLIENT_SECRET ?? "",
discoveryUrl: OIDC_ISSUER
? `${OIDC_ISSUER}/.well-known/openid-configuration`
: undefined,
// When OIDC_INTERNAL_BASE is set, use explicit URLs to avoid hairpin NAT:
// - authorizationUrl: external (browser redirect, no server-side fetch)
// - tokenUrl/userInfoUrl: internal (server-to-server, avoids hairpin)
// When not set, fall back to discoveryUrl for local dev.
...(OIDC_INTERNAL_BASE
? {
authorizationUrl: `${new URL(OIDC_ISSUER!).origin}/application/o/authorize/`,
tokenUrl: `${OIDC_INTERNAL_BASE}/application/o/token/`,
userInfoUrl: `${OIDC_INTERNAL_BASE}/application/o/userinfo/`,
}
: {
discoveryUrl: OIDC_ISSUER
? `${OIDC_ISSUER}/.well-known/openid-configuration`
: undefined,
}),
scopes: ["openid", "profile", "email"],
},
],
+6
View File
@@ -23,6 +23,12 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
}
if (process.env.AUTH_DISABLED === "true") {
const devUserId = c.req.header("X-Dev-User-Id");
const sub = devUserId ?? "dev-user";
+6
View File
@@ -22,6 +22,12 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
c,
next
) => {
// Better-Auth's own routes handle their own auth — skip staff resolution
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
}
const db = getDb();
if (process.env.AUTH_DISABLED === "true") {
+100 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod/v3";
import { and, eq, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
import { and, eq, lt, gt, ne, getDb, appointments, impersonationSessions, waitlistEntries } from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const portalRouter = new Hono<AppEnv>();
@@ -212,6 +212,105 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
});
});
// ─── Appointment reschedule ──────────────────────────────────────────────────
const rescheduleSchema = z.object({
startTime: z.string().datetime(),
});
portalRouter.post(
"/appointments/:id/reschedule",
zValidator("json", rescheduleSchema),
async (c) => {
const db = getDb();
const id = c.req.param("id");
const body = c.req.valid("json");
const sessionId = c.req.header("X-Impersonation-Session-Id");
if (!sessionId) {
return c.json({ error: "Unauthorized" }, 401);
}
const [session] = await db
.select()
.from(impersonationSessions)
.where(
and(
eq(impersonationSessions.id, sessionId),
eq(impersonationSessions.status, "active")
)
)
.limit(1);
if (!session || session.expiresAt <= new Date()) {
return c.json({ error: "Unauthorized" }, 401);
}
const [appt] = await db
.select()
.from(appointments)
.where(eq(appointments.id, id))
.limit(1);
if (!appt) {
return c.json({ error: "Not found" }, 404);
}
if (appt.clientId !== session.clientId) {
return c.json({ error: "Forbidden" }, 403);
}
if (appt.startTime <= new Date()) {
return c.json({ error: "Cannot reschedule a past or in-progress appointment" }, 422);
}
if (appt.status === "cancelled" || appt.status === "completed") {
return c.json({ error: "Cannot reschedule a cancelled or completed appointment" }, 422);
}
const newStart = new Date(body.startTime);
const durationMs = appt.endTime.getTime() - appt.startTime.getTime();
const newEnd = new Date(newStart.getTime() + durationMs);
const [existingConflict] = await db
.select({ id: appointments.id })
.from(appointments)
.where(
and(
eq(appointments.staffId, appt.staffId!),
lt(appointments.startTime, newEnd),
gt(appointments.endTime, newStart),
ne(appointments.status, "cancelled"),
ne(appointments.status, "no_show"),
ne(appointments.id, id)
)
)
.limit(1);
if (existingConflict) {
return c.json({ error: "The selected time slot is no longer available" }, 409);
}
const [updated] = await db
.update(appointments)
.set({ startTime: newStart, endTime: newEnd, updatedAt: new Date() })
.where(eq(appointments.id, id))
.returning();
if (!updated) {
return c.json({ error: "Not found" }, 404);
}
return c.json({
id: updated.id,
startTime: updated.startTime,
endTime: updated.endTime,
status: updated.status,
updatedAt: updated.updatedAt,
});
}
);
// ─── Client-facing waitlist routes ───────────────────────────────────────────
const createWaitlistEntrySchema = z.object({
+57 -3
View File
@@ -19,6 +19,61 @@ import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async () => {
setIsLoading(true);
await signIn.social({ provider: "authentik", callbackURL: window.location.origin });
};
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontFamily: "system-ui, sans-serif",
background: "#f0f2f5",
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: "2rem 2.5rem",
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
textAlign: "center",
minWidth: 280,
}}
>
<h1 style={{ fontSize: 22, marginBottom: "0.5rem", color: "#1a202c" }}>GroomBook</h1>
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue
</p>
<button
onClick={handleLogin}
disabled={isLoading}
style={{
padding: "0.6rem 1.5rem",
borderRadius: 6,
border: "none",
background: "#4f8a6f",
color: "#fff",
fontWeight: 600,
fontSize: 14,
cursor: isLoading ? "wait" : "pointer",
opacity: isLoading ? 0.7 : 1,
}}
>
{isLoading ? "Redirecting…" : "Sign in with SSO"}
</button>
</div>
</div>
);
}
const NAV_LINKS = [
{ to: "/admin", label: "Appointments" },
{ to: "/admin/clients", label: "Clients" },
@@ -170,10 +225,9 @@ export function App() {
return <Navigate to="/login" replace />;
}
// Production mode: if no session, redirect to Authentik sign-in
// Production mode: if no session, show login page (avoids redirect loops)
if (!authDisabled && !session) {
signIn.social({ provider: "authentik" });
return null;
return <LoginPage />;
}
return (
+1 -1
View File
@@ -1,7 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL ?? "http://localhost:3000",
baseURL: import.meta.env.VITE_API_URL ?? "",
});
export const { signIn, signOut, useSession } = authClient;
+18 -2
View File
@@ -5,7 +5,7 @@ import {
Settings, LogOut, Shield,
} from "lucide-react";
import { Dashboard } from "./sections/Dashboard.js";
import { AppointmentsSection } from "./sections/Appointments.js";
import { AppointmentsSection, RescheduleFlow } from "./sections/Appointments.js";
import { PetProfiles } from "./sections/PetProfiles.js";
import { ReportCards } from "./sections/ReportCards.js";
import { BillingPayments } from "./sections/BillingPayments.js";
@@ -33,6 +33,8 @@ export function CustomerPortal() {
const [activeSection, setActiveSection] = useState<Section>("dashboard");
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [showAuditLog, setShowAuditLog] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Record<string, unknown> | null>(null);
const [session, setSession] = useState<ImpersonationSession | null>(null);
const [sessionExtended, setSessionExtended] = useState(false);
const { branding } = useBranding();
@@ -107,12 +109,17 @@ export function CustomerPortal() {
}
};
const handleReschedule = useCallback((appointment: Record<string, unknown>) => {
setRescheduleAppointment(appointment);
setShowReschedule(true);
}, []);
const isReadOnly = session?.status === "active";
const renderSection = () => {
switch (activeSection) {
case "dashboard":
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} onReschedule={handleReschedule} />;
case "appointments":
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
case "pets":
@@ -158,6 +165,15 @@ export function CustomerPortal() {
/>
)}
{showReschedule && rescheduleAppointment && (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<RescheduleFlow
appointment={rescheduleAppointment as any}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={session?.id ?? null}
/>
)}
{/* Mobile Header */}
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-stone-200">
<button
@@ -1,6 +1,7 @@
import { useState } from "react";
import { User, Lock, PawPrint, FileCheck, Plus, Archive } from "lucide-react";
import { CUSTOMER, PETS, SIGNED_AGREEMENTS } from "../mockData.js";
import { PetForm } from "./PetForm.js";
interface Props {
readOnly: boolean;
@@ -112,6 +113,20 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
}
function ManagePets({ readOnly }: { readOnly: boolean }) {
const [editingPetId, setEditingPetId] = useState<string | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
if (editingPet || showAddForm) {
return (
<PetForm
pet={editingPet ?? undefined}
onSave={() => { setEditingPetId(null); setShowAddForm(false); }}
onCancel={() => { setEditingPetId(null); setShowAddForm(false); }}
/>
);
}
return (
<div className="space-y-4">
{PETS.map(pet => (
@@ -126,17 +141,12 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
{!readOnly && (
<div className="flex gap-2">
<button
disabled
title="Pet editing coming soon"
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-400 cursor-not-allowed"
onClick={() => setEditingPetId(pet.id)}
className="px-3 py-1.5 border border-stone-200 rounded-lg text-xs text-stone-600 hover:bg-stone-50"
>
Edit
</button>
<button
disabled
title="Pet archiving coming soon"
className="p-1.5 border border-stone-200 rounded-lg text-stone-300 cursor-not-allowed"
>
<button className="p-1.5 border border-stone-200 rounded-lg text-stone-400 hover:text-amber-600 hover:border-amber-200">
<Archive size={14} />
</button>
</div>
@@ -145,9 +155,8 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
))}
{!readOnly && (
<button
disabled
title="Adding pets coming soon"
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-400 cursor-not-allowed"
onClick={() => setShowAddForm(true)}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-stone-300 rounded-2xl text-sm text-stone-500 hover:border-(--color-accent) hover:text-(--color-accent-dark) transition-colors"
>
<Plus size={16} />
Add New Pet
+141 -7
View File
@@ -49,6 +49,8 @@ const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
export function AppointmentsSection({ readOnly, sessionId }: Props) {
const [showBooking, setShowBooking] = useState(false);
const [showReschedule, setShowReschedule] = useState(false);
const [rescheduleAppointment, setRescheduleAppointment] = useState<Appointment | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [tab, setTab] = useState<"upcoming" | "past">("upcoming");
@@ -90,6 +92,7 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
readOnly={readOnly}
sessionId={sessionId}
onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }}
/>
))}
{UPCOMING_APPOINTMENTS.length === 0 && (
@@ -108,6 +111,7 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
readOnly={readOnly}
sessionId={sessionId}
onReschedule={(appt) => { setRescheduleAppointment(appt); setShowReschedule(true); }}
/>
))}
</div>
@@ -119,14 +123,21 @@ export function AppointmentsSection({ readOnly, sessionId }: Props) {
readOnly={readOnly}
/>
)}
{showReschedule && rescheduleAppointment && (
<RescheduleFlow
appointment={rescheduleAppointment}
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
sessionId={sessionId}
/>
)}
</div>
);
}
function AppointmentCard({
appointment: appt, expanded, onToggle, readOnly, sessionId,
appointment: appt, expanded, onToggle, readOnly, sessionId, onReschedule,
}: {
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null;
appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; onReschedule: (appt: Appointment) => void;
}) {
return (
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
@@ -176,11 +187,7 @@ function AppointmentCard({
)}
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
<div className="flex gap-2 mt-3">
<button
disabled
title="Rescheduling coming soon"
className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
>
<button onClick={() => onReschedule(appt)} className="text-xs px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Reschedule
</button>
<CancelAppointmentButton appointment={appt} sessionId={sessionId} />
@@ -376,6 +383,133 @@ export function CustomerNotesSection({ appointment: appt, sessionId }: { appoint
);
}
export function RescheduleFlow({
appointment: appt,
onClose,
sessionId,
}: {
appointment: Appointment;
onClose: () => void;
sessionId?: string | null;
}) {
const [selectedDate, setSelectedDate] = useState("");
const [selectedTime, setSelectedTime] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const availableTimes = ["9:00 AM", "10:00 AM", "11:00 AM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM"];
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
const [hoursMinutes = "", period = ""] = selectedTime.split(" ");
const [hoursStr = "0", minutesStr = "0"] = hoursMinutes.split(":");
let hours = parseInt(hoursStr, 10);
const minutes = parseInt(minutesStr ?? "0", 10);
if (period === "PM" && hours !== 12) hours += 12;
if (period === "AM" && hours === 12) hours = 0;
const isoTime = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`;
const startTime = new Date(`${selectedDate}T${isoTime}`).toISOString();
setSubmitting(true);
setError(null);
try {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (sessionId) headers["X-Impersonation-Session-Id"] = sessionId;
const res = await fetch(`/api/portal/appointments/${appt.id}/reschedule`, {
method: "POST",
headers,
body: JSON.stringify({ startTime }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to reschedule" }));
throw new Error(err.error || `HTTP ${res.status}`);
}
setSuccess(true);
setTimeout(() => { window.location.reload(); }, 1500);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to reschedule");
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-stone-200">
<h2 className="font-semibold text-stone-800">Reschedule Appointment</h2>
<button onClick={onClose} className="text-stone-400 hover:text-stone-600"></button>
</div>
<div className="p-5">
{success ? (
<div className="text-center py-8">
<div className="text-4xl mb-3"></div>
<h3 className="text-lg font-semibold text-stone-800 mb-1">Appointment Rescheduled!</h3>
<p className="text-sm text-stone-500">Redirecting...</p>
</div>
) : (
<>
{/* Current appointment summary */}
<div className="bg-stone-50 rounded-xl p-4 mb-4 text-sm">
<p className="font-medium text-stone-800">{appt.petName} {appt.services.join(", ")}</p>
<p className="text-stone-500 mt-0.5">
{formatDate(appt.date)} at {appt.time} with {appt.groomerName}
</p>
</div>
<h3 className="font-medium text-stone-800 mb-3">Pick a New Date & Time</h3>
<input
type="date"
value={selectedDate}
onChange={e => setSelectedDate(e.target.value)}
min={new Date().toISOString().split("T")[0]}
className="w-full border border-stone-300 rounded-lg px-3 py-2 text-sm mb-3"
/>
{selectedDate && (
<div className="grid grid-cols-3 gap-2 mb-4">
{availableTimes.map(time => (
<button
key={time}
onClick={() => setSelectedTime(time)}
className={`px-3 py-2 rounded-lg text-sm border ${
selectedTime === time
? "border-(--color-accent) bg-(--color-accent-light) font-medium"
: "border-stone-200 hover:border-stone-300"
}`}
>
{time}
</button>
))}
</div>
)}
{error && <p className="text-xs text-red-500 mb-3">{error}</p>}
<div className="flex gap-2">
<button
onClick={onClose}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!selectedDate || !selectedTime || submitting}
className="flex-1 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover) disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "Rescheduling..." : "Confirm Reschedule"}
</button>
</div>
</>
)}
</div>
</div>
</div>
);
}
function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boolean }) {
const [step, setStep] = useState(1);
const [selectedPet, setSelectedPet] = useState<Pet | null>(null);
+7 -14
View File
@@ -4,6 +4,8 @@ import { PETS, UPCOMING_APPOINTMENTS, PAST_APPOINTMENTS, INVOICES, LOYALTY, BUSI
interface Props {
onNavigate: (section: "appointments" | "pets" | "billing" | "reports") => void;
readOnly: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onReschedule?: (appointment: any) => void;
}
function daysUntil(dateStr: string): number {
@@ -18,7 +20,7 @@ function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
}
export function Dashboard({ onNavigate, readOnly }: Props) {
export function Dashboard({ onNavigate, readOnly, onReschedule }: Props) {
const nextAppt = UPCOMING_APPOINTMENTS[0];
const outstanding = INVOICES.filter(i => i.status === "outstanding").reduce((sum, i) => sum + i.amount, 0);
const recentEvents = [
@@ -78,24 +80,15 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{!readOnly && (
<div className="flex gap-2 mt-4">
<button
disabled
title="Rescheduling coming soon"
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
onClick={() => onReschedule?.(nextAppt)}
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50"
>
Reschedule
</button>
<button
disabled
title="Cancellation coming soon"
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
>
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Cancel
</button>
<button
disabled
title="Notes coming soon"
className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-400 cursor-not-allowed"
>
<button className="text-sm px-3 py-1.5 border border-stone-200 rounded-lg text-stone-600 hover:bg-stone-50">
Add Notes
</button>
</div>
+87
View File
@@ -0,0 +1,87 @@
import { useState } from "react";
import { X, Save } from "lucide-react";
import type { Pet } from "../mockData.js";
interface Props {
pet?: Pet;
onSave: (pet: Pet) => void;
onCancel: () => void;
}
export function PetForm({ pet, onSave, onCancel }: Props) {
const [name, setName] = useState(pet?.name ?? "");
const [breed, setBreed] = useState(pet?.breed ?? "");
const [weight, setWeight] = useState(pet?.weight ?? 0);
const [notes, setNotes] = useState(pet?.allergies ?? "");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!pet) return;
onSave({ ...pet, name, breed, weight, allergies: notes });
}
return (
<div className="bg-white rounded-2xl border border-stone-200 p-6 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-stone-800">{pet ? "Edit Pet" : "Add Pet"}</h2>
<button onClick={onCancel} className="p-2 hover:bg-stone-50 rounded-lg">
<X size={16} className="text-stone-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Breed</label>
<input
type="text"
value={breed}
onChange={e => setBreed(e.target.value)}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Weight (lbs)</label>
<input
type="number"
value={weight}
onChange={e => setWeight(Number(e.target.value))}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-600 mb-1">Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 border border-stone-200 rounded-lg text-sm text-stone-600 hover:bg-stone-50"
>
Cancel
</button>
<button
type="submit"
className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-(--color-accent) text-white rounded-lg text-sm font-medium hover:bg-(--color-accent-hover)"
>
<Save size={14} />
Save
</button>
</div>
</form>
</div>
);
}
+15 -2
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { PawPrint, Heart, Scissors, Syringe, AlertTriangle, CheckCircle, Clock, Upload, Edit3 } from "lucide-react";
import { PETS, PAST_APPOINTMENTS } from "../mockData.js";
import type { Pet } from "../mockData.js";
import { PetForm } from "./PetForm.js";
interface Props {
readOnly: boolean;
@@ -17,9 +18,21 @@ const VAX_STATUS_STYLES: Record<VaxStatus, { bg: string; text: string; icon: typ
export function PetProfiles({ readOnly }: Props) {
const [selectedPetId, setSelectedPetId] = useState<string>(PETS[0]?.id ?? "");
const [activeTab, setActiveTab] = useState<"info" | "medical" | "grooming" | "vaccinations" | "history">("info");
const [editingPetId, setEditingPetId] = useState<string | null>(null);
const pet = PETS.find(p => p.id === selectedPetId)!;
const petHistory = PAST_APPOINTMENTS.filter(a => a.petId === selectedPetId);
const editingPet = editingPetId ? PETS.find(p => p.id === editingPetId) ?? undefined : undefined;
if (editingPet) {
return (
<PetForm
pet={editingPet}
onSave={() => setEditingPetId(null)}
onCancel={() => setEditingPetId(null)}
/>
);
}
return (
<div className="space-y-6">
@@ -54,8 +67,8 @@ export function PetProfiles({ readOnly }: Props) {
<p className="text-stone-400 text-xs mt-0.5">Born {new Date(pet.dob).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}</p>
</div>
{!readOnly && (
<button disabled title="Pet editing coming soon" className="p-2 rounded-lg cursor-not-allowed">
<Edit3 size={16} className="text-stone-300" />
<button onClick={() => setEditingPetId(pet.id)} className="p-2 hover:bg-stone-50 rounded-lg">
<Edit3 size={16} className="text-stone-400" />
</button>
)}
</div>
@@ -0,0 +1,84 @@
CREATE TYPE "public"."waitlist_status" AS ENUM('active', 'notified', 'expired', 'cancelled');--> statement-breakpoint
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "waitlist_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_id" uuid NOT NULL,
"pet_id" uuid NOT NULL,
"service_id" uuid NOT NULL,
"preferred_date" text NOT NULL,
"preferred_time" text NOT NULL,
"status" "waitlist_status" DEFAULT 'active' NOT NULL,
"notified_at" timestamp,
"expires_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_status" text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmed_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "cancelled_at" timestamp;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "confirmation_token" text;--> statement-breakpoint
ALTER TABLE "appointments" ADD COLUMN "customer_notes" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_key" text;--> statement-breakpoint
ALTER TABLE "pets" ADD COLUMN "photo_uploaded_at" timestamp;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "user_id" text;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "staff" ADD COLUMN "ical_token" text;--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_service_id_services_id_fk" FOREIGN KEY ("service_id") REFERENCES "public"."services"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_waitlist_client_id" ON "waitlist_entries" USING btree ("client_id");--> statement-breakpoint
CREATE INDEX "idx_waitlist_preferred_date" ON "waitlist_entries" USING btree ("preferred_date");--> statement-breakpoint
CREATE INDEX "idx_waitlist_status" ON "waitlist_entries" USING btree ("status");--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_confirmation_token_unique" UNIQUE("confirmation_token");--> statement-breakpoint
ALTER TABLE "staff" ADD CONSTRAINT "staff_ical_token_unique" UNIQUE("ical_token");
+1
View File
@@ -52,6 +52,7 @@ export function buildStaff(overrides: Partial<StaffRow> = {}): StaffRow {
oidcSub: `oidc-${id}`,
userId: null,
role: "groomer",
isSuperUser: false,
active: true,
icalToken: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+2
View File
@@ -159,6 +159,8 @@ export const staff = pgTable("staff", {
// Better-Auth user ID — links staff business record to auth identity
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
role: staffRoleEnum("role").notNull().default("groomer"),
// Super users bypass appointment-booking restrictions and access admin panels
isSuperUser: boolean("is_super_user").notNull().default(false),
active: boolean("active").notNull().default(true),
// Token for iCal calendar feed subscription (no auth required)
icalToken: text("ical_token").unique(),
+10 -8
View File
@@ -287,6 +287,7 @@ async function seedKnownUsers() {
email: "demo-manager@groombook.dev",
oidcSub: "demo-manager-001",
role: "manager",
isSuperUser: true,
active: true,
});
console.log("✓ Created staff 'Demo Manager' (oidcSub: demo-manager-001)");
@@ -384,24 +385,24 @@ async function seed() {
// ── Staff ──
// Deterministic staff IDs so they can be referenced in scripts/tests
const managerStaff = [
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const },
{ id: uuid(), name: "Jordan Lee", email: "jordan@groombook.dev", role: "manager" as const, isSuperUser: true },
];
const receptionistStaff = [
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const },
{ id: uuid(), name: "Sam Rivera", email: "sam@groombook.dev", role: "receptionist" as const, isSuperUser: false },
];
const groomers = [
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Sarah Mitchell", email: "sarah@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "James Park", email: "james@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "Maria Gonzalez", email: "maria@groombook.dev", role: "groomer" as const, isSuperUser: false },
];
// Bathers are groomers by role but serve as the secondary staff (bather) on appointments
const bathers = [
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const },
{ id: uuid(), name: "Tyler Johnson", email: "tyler@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "Ashley Chen", email: "ashley@groombook.dev", role: "groomer" as const, isSuperUser: false },
{ id: uuid(), name: "Devon Williams", email: "devon@groombook.dev", role: "groomer" as const, isSuperUser: false },
];
const allStaff = [...managerStaff, ...receptionistStaff, ...groomers, ...bathers];
@@ -411,6 +412,7 @@ async function seed() {
name: s.name,
email: s.email,
role: s.role,
isSuperUser: s.isSuperUser,
active: true,
});
}
+1
View File
@@ -72,6 +72,7 @@ export interface Staff {
name: string;
email: string;
role: "groomer" | "receptionist" | "manager";
isSuperUser: boolean;
active: boolean;
createdAt: string;
updatedAt: string;