feat: add waitlist entries table and API routes (GRO-105)
- Migration 0015: new waitlist_entries table with indexes - Schema update: add waitlistEntries table and waitlistStatusEnum - Staff API: GET /api/waitlist, GET /api/waitlist/:id - Portal API: POST /api/waitlist (via impersonation session), DELETE /api/waitlist/:id - Note: cancellation hook and email notification pending Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed by
Flea Flicker
parent
012b6bc1cd
commit
232827ad29
@@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js";
|
||||
import { petsRouter } from "./routes/pets.js";
|
||||
import { servicesRouter } from "./routes/services.js";
|
||||
import { appointmentsRouter } from "./routes/appointments.js";
|
||||
import { waitlistRouter } from "./routes/waitlist.js";
|
||||
import { portalRouter } from "./routes/portal.js";
|
||||
import { staffRouter } from "./routes/staff.js";
|
||||
import { invoicesRouter } from "./routes/invoices.js";
|
||||
@@ -112,6 +113,7 @@ api.route("/pets", petsRouter);
|
||||
api.route("/services", servicesRouter);
|
||||
api.route("/appointments", appointmentsRouter);
|
||||
api.route("/portal", portalRouter);
|
||||
api.route("/waitlist", waitlistRouter);
|
||||
api.route("/staff", staffRouter);
|
||||
api.route("/invoices", invoicesRouter);
|
||||
api.route("/reports", reportsRouter);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Hono } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
waitlistEntries,
|
||||
clients,
|
||||
pets,
|
||||
services,
|
||||
impersonationSessions,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const waitlistRouter = new Hono<AppEnv>();
|
||||
|
||||
const waitlistStatusEnum = z.enum(["active", "notified", "expired", "cancelled"]);
|
||||
|
||||
const createWaitlistEntrySchema = z.object({
|
||||
petId: z.string().uuid(),
|
||||
serviceId: z.string().uuid(),
|
||||
preferredDate: z.string(),
|
||||
preferredTime: z.string(),
|
||||
});
|
||||
|
||||
const updateWaitlistEntrySchema = z.object({
|
||||
status: waitlistStatusEnum.optional(),
|
||||
preferredDate: z.string().optional(),
|
||||
preferredTime: z.string().optional(),
|
||||
});
|
||||
|
||||
waitlistRouter.get("/", async (c) => {
|
||||
const db = getDb();
|
||||
const date = c.req.query("date");
|
||||
|
||||
const conditions = [];
|
||||
if (date) {
|
||||
conditions.push(eq(waitlistEntries.preferredDate, date));
|
||||
}
|
||||
|
||||
const rows =
|
||||
conditions.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(and(...conditions))
|
||||
.orderBy(waitlistEntries.createdAt)
|
||||
: await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.orderBy(waitlistEntries.createdAt);
|
||||
|
||||
const enriched = await Promise.all(
|
||||
rows.map(async (entry) => {
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, entry.clientId))
|
||||
.limit(1);
|
||||
const [pet] = await db
|
||||
.select({ name: pets.name })
|
||||
.from(pets)
|
||||
.where(eq(pets.id, entry.petId))
|
||||
.limit(1);
|
||||
const [service] = await db
|
||||
.select({ name: services.name })
|
||||
.from(services)
|
||||
.where(eq(services.id, entry.serviceId))
|
||||
.limit(1);
|
||||
return {
|
||||
...entry,
|
||||
clientName: client?.name ?? null,
|
||||
clientEmail: client?.email ?? null,
|
||||
petName: pet?.name ?? null,
|
||||
serviceName: service?.name ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
waitlistRouter.get("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, c.req.param("id")))
|
||||
.limit(1);
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
});
|
||||
|
||||
waitlistRouter.post(
|
||||
"/",
|
||||
zValidator("json", createWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
let clientId: string | null = null;
|
||||
if (sessionId) {
|
||||
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()) {
|
||||
clientId = session.clientId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.insert(waitlistEntries)
|
||||
.values({
|
||||
clientId,
|
||||
petId: body.petId,
|
||||
serviceId: body.serviceId,
|
||||
preferredDate: body.preferredDate,
|
||||
preferredTime: body.preferredTime,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return c.json(entry, 201);
|
||||
}
|
||||
);
|
||||
|
||||
waitlistRouter.patch(
|
||||
"/:id",
|
||||
zValidator("json", updateWaitlistEntrySchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
||||
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
|
||||
|
||||
const [updated] = await db
|
||||
.update(waitlistEntries)
|
||||
.set(updateData)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
waitlistRouter.delete("/:id", async (c) => {
|
||||
const db = getDb();
|
||||
const id = c.req.param("id");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
|
||||
if (sessionId) {
|
||||
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()) {
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.limit(1);
|
||||
if (entry && entry.clientId !== session.clientId) {
|
||||
return c.json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(waitlistEntries)
|
||||
.where(eq(waitlistEntries.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) return c.json({ error: "Not found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE waitlist_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
pet_id UUID NOT NULL REFERENCES pets(id) ON DELETE CASCADE,
|
||||
service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
preferred_date DATE NOT NULL,
|
||||
preferred_time TIME NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
notified_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_waitlist_client_id ON waitlist_entries (client_id);
|
||||
CREATE INDEX idx_waitlist_preferred_date ON waitlist_entries (preferred_date);
|
||||
CREATE INDEX idx_waitlist_status ON waitlist_entries (status) WHERE status = 'active';
|
||||
CREATE UNIQUE INDEX idx_waitlist_active_unique ON waitlist_entries (client_id, pet_id, service_id, preferred_date, preferred_time) WHERE status = 'active';
|
||||
@@ -312,3 +312,38 @@ export const groomingVisitLogs = pgTable("grooming_visit_logs", {
|
||||
groomedAt: timestamp("groomed_at").notNull().defaultNow(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const waitlistStatusEnum = pgEnum("waitlist_status", [
|
||||
"active",
|
||||
"notified",
|
||||
"expired",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export const waitlistEntries = pgTable(
|
||||
"waitlist_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
clientId: uuid("client_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
petId: uuid("pet_id")
|
||||
.notNull()
|
||||
.references(() => pets.id, { onDelete: "cascade" }),
|
||||
serviceId: uuid("service_id")
|
||||
.notNull()
|
||||
.references(() => services.id, { onDelete: "cascade" }),
|
||||
preferredDate: text("preferred_date").notNull(),
|
||||
preferredTime: text("preferred_time").notNull(),
|
||||
status: waitlistStatusEnum("status").notNull().default("active"),
|
||||
notifiedAt: timestamp("notified_at"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("idx_waitlist_client_id").on(t.clientId),
|
||||
index("idx_waitlist_preferred_date").on(t.preferredDate),
|
||||
index("idx_waitlist_status").on(t.status),
|
||||
]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user