feat: detailed pet profile attributes and grooming visit history (closes #13)

- Add cut_style, shampoo_preference, special_care_notes, custom_fields columns to pets table
- Add grooming_visit_logs table to track per-visit grooming details (cut, products, notes)
- Extend pets API to accept and return new profile fields
- Add /api/grooming-logs endpoint (GET by petId, POST, DELETE)
- Update Pet type with new fields; add GroomingVisitLog type
- Update Clients page: grooming preferences section in pet card, "Log visit" button,
  visit history panel showing last 3 visits, expanded pet form with grooming preferences

Co-authored-by: Groom Book CTO <cto@groombook.app>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit was merged in pull request #32.
This commit is contained in:
groombook-paperclip[bot]
2026-03-17 21:46:40 +00:00
committed by GitHub
parent f47717dfd8
commit 14ed19497f
8 changed files with 392 additions and 12 deletions
+2
View File
@@ -11,6 +11,7 @@ import { invoicesRouter } from "./routes/invoices.js";
import { bookRouter } from "./routes/book.js";
import { reportsRouter } from "./routes/reports.js";
import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { authMiddleware } from "./middleware/auth.js";
import { startReminderScheduler } from "./services/reminders.js";
@@ -44,6 +45,7 @@ api.route("/staff", staffRouter);
api.route("/invoices", invoicesRouter);
api.route("/reports", reportsRouter);
api.route("/appointment-groups", appointmentGroupsRouter);
api.route("/grooming-logs", groomingLogsRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+56
View File
@@ -0,0 +1,56 @@
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
export const groomingLogsRouter = new Hono();
const createLogSchema = z.object({
petId: z.string().uuid(),
appointmentId: z.string().uuid().optional(),
staffId: z.string().uuid().optional(),
cutStyle: z.string().max(500).optional(),
productsUsed: z.string().max(1000).optional(),
notes: z.string().max(2000).optional(),
groomedAt: z.string().datetime().optional(),
});
// GET /api/grooming-logs?petId=<uuid>
groomingLogsRouter.get("/", async (c) => {
const db = getDb();
const petId = c.req.query("petId");
if (!petId) return c.json({ error: "petId is required" }, 400);
const rows = await db
.select()
.from(groomingVisitLogs)
.where(eq(groomingVisitLogs.petId, petId))
.orderBy(desc(groomingVisitLogs.groomedAt));
return c.json(rows);
});
groomingLogsRouter.post(
"/",
zValidator("json", createLogSchema),
async (c) => {
const db = getDb();
const { groomedAt, ...rest } = c.req.valid("json");
const [row] = await db
.insert(groomingVisitLogs)
.values({
...rest,
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
})
.returning();
return c.json(row, 201);
}
);
groomingLogsRouter.delete("/:id", async (c) => {
const db = getDb();
const [row] = await db
.delete(groomingVisitLogs)
.where(eq(groomingVisitLogs.id, c.req.param("id")))
.returning();
if (!row) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
+8 -2
View File
@@ -14,6 +14,10 @@ const createPetSchema = z.object({
dateOfBirth: z.string().datetime().optional(),
healthAlerts: z.string().max(2000).optional(),
groomingNotes: z.string().max(2000).optional(),
cutStyle: z.string().max(500).optional(),
shampooPreference: z.string().max(500).optional(),
specialCareNotes: z.string().max(2000).optional(),
customFields: z.record(z.string(), z.string()).optional(),
});
const updatePetSchema = createPetSchema.partial().omit({ clientId: true });
@@ -42,13 +46,14 @@ petsRouter.get("/:id", async (c) => {
petsRouter.post("/", zValidator("json", createPetSchema), async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, ...rest } = c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.insert(pets)
.values({
...rest,
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
customFields: customFields ?? {},
})
.returning();
return c.json(row, 201);
@@ -59,13 +64,14 @@ petsRouter.patch(
zValidator("json", updatePetSchema),
async (c) => {
const db = getDb();
const { weightKg, dateOfBirth, ...rest } = c.req.valid("json");
const { weightKg, dateOfBirth, customFields, ...rest } = c.req.valid("json");
const [row] = await db
.update(pets)
.set({
...rest,
weightKg: weightKg?.toString(),
dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined,
...(customFields !== undefined ? { customFields } : {}),
updatedAt: new Date(),
})
.where(eq(pets.id, c.req.param("id")))