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:
@@ -6,6 +6,7 @@ import { clientsRouter } from "./routes/clients.js";
|
|||||||
import { petsRouter } from "./routes/pets.js";
|
import { petsRouter } from "./routes/pets.js";
|
||||||
import { servicesRouter } from "./routes/services.js";
|
import { servicesRouter } from "./routes/services.js";
|
||||||
import { appointmentsRouter } from "./routes/appointments.js";
|
import { appointmentsRouter } from "./routes/appointments.js";
|
||||||
|
import { waitlistRouter } from "./routes/waitlist.js";
|
||||||
import { portalRouter } from "./routes/portal.js";
|
import { portalRouter } from "./routes/portal.js";
|
||||||
import { staffRouter } from "./routes/staff.js";
|
import { staffRouter } from "./routes/staff.js";
|
||||||
import { invoicesRouter } from "./routes/invoices.js";
|
import { invoicesRouter } from "./routes/invoices.js";
|
||||||
@@ -109,6 +110,7 @@ api.route("/pets", petsRouter);
|
|||||||
api.route("/services", servicesRouter);
|
api.route("/services", servicesRouter);
|
||||||
api.route("/appointments", appointmentsRouter);
|
api.route("/appointments", appointmentsRouter);
|
||||||
api.route("/portal", portalRouter);
|
api.route("/portal", portalRouter);
|
||||||
|
api.route("/waitlist", waitlistRouter);
|
||||||
api.route("/staff", staffRouter);
|
api.route("/staff", staffRouter);
|
||||||
api.route("/invoices", invoicesRouter);
|
api.route("/invoices", invoicesRouter);
|
||||||
api.route("/reports", reportsRouter);
|
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(),
|
groomedAt: timestamp("groomed_at").notNull().defaultNow(),
|
||||||
createdAt: timestamp("created_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