fix: address CTO review — visitCount bug + upcomingAppointment date filter
- Replace .select({ count: appointments.id }).limit(1) + .length with
sql<number>`count(*)::int` pattern per project standard (references invoices.ts:86)
- Add gte(appointments.startTime, new Date()) to upcomingAppointment query
so past appointments in scheduled/confirmed status are excluded
- Add visitCount regression tests: 2+ completed appointments → visitCount >= 2,
no completed → visitCount = 0
Updated UAT_PLAYBOOK.md §profile-summary (visitCount regression + date filter)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -220,7 +220,9 @@ vi.mock("../db/index.js", () => {
|
|||||||
desc: vi.fn((c: unknown) => c),
|
desc: vi.fn((c: unknown) => c),
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
exists: vi.fn(() => true),
|
exists: vi.fn(() => true),
|
||||||
|
gte: vi.fn((a: unknown, b: unknown) => ({ col: a, val: b })),
|
||||||
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
or: vi.fn((a: unknown, b: unknown) => [a, b]),
|
||||||
|
sql: vi.fn((str: string) => str),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,6 +294,54 @@ describe("GET /:id/profile-summary", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /:id/profile-summary — visitCount", () => {
|
||||||
|
beforeEach(resetMock);
|
||||||
|
|
||||||
|
it("returns visitCount >= 2 when pet has 2+ completed appointments", async () => {
|
||||||
|
const app = makeApp(MANAGER);
|
||||||
|
// Add a second completed appointment
|
||||||
|
mock.appointments = [
|
||||||
|
...mock.appointments,
|
||||||
|
{
|
||||||
|
id: "appt-completed-2",
|
||||||
|
clientId: CLIENT_ID,
|
||||||
|
petId: PET_ID,
|
||||||
|
serviceId: "service-1",
|
||||||
|
staffId: "staff-groomer-id",
|
||||||
|
batherStaffId: null,
|
||||||
|
status: "completed",
|
||||||
|
startTime: new Date("2024-07-01T09:00:00Z"),
|
||||||
|
endTime: new Date("2024-07-01T11:00:00Z"),
|
||||||
|
notes: null,
|
||||||
|
priceCents: 6000,
|
||||||
|
seriesId: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
groupId: null,
|
||||||
|
confirmationStatus: "confirmed",
|
||||||
|
confirmedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
confirmationToken: null,
|
||||||
|
customerNotes: null,
|
||||||
|
createdAt: new Date("2024-06-15"),
|
||||||
|
updatedAt: new Date("2024-06-15"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.visitCount).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns visitCount = 0 when no completed appointments", async () => {
|
||||||
|
const app = makeApp(MANAGER);
|
||||||
|
mock.appointments = mock.appointments.map((a) => ({ ...a, status: "cancelled" }));
|
||||||
|
const res = await app.request(`/pets/${PET_ID}/profile-summary`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.visitCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /:id/profile-summary — empty history", () => {
|
describe("GET /:id/profile-summary — empty history", () => {
|
||||||
beforeEach(resetMock);
|
beforeEach(resetMock);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, desc, eq, exists, getDb, groomingVisitLogs, or, pets, appointments, staff, services } from "../db/index.js";
|
import { and, desc, eq, exists, getDb, gte, groomingVisitLogs, or, pets, appointments, staff, services, sql } from "../db/index.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import {
|
import {
|
||||||
getPresignedUploadUrl,
|
getPresignedUploadUrl,
|
||||||
@@ -362,13 +362,10 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
|||||||
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
|
const lastVisitDate = historyRows[0]?.groomedAt?.toISOString() ?? null;
|
||||||
|
|
||||||
// Completed appointment count for this pet
|
// Completed appointment count for this pet
|
||||||
const countResult = await db
|
const [{ count: visitCount }] = await db
|
||||||
.select({ count: appointments.id })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(appointments)
|
.from(appointments)
|
||||||
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")))
|
.where(and(eq(appointments.petId, petId), eq(appointments.status, "completed")));
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const visitCount = countResult.length;
|
|
||||||
|
|
||||||
// Upcoming appointment: next scheduled or confirmed
|
// Upcoming appointment: next scheduled or confirmed
|
||||||
const [nextAppt] = await db
|
const [nextAppt] = await db
|
||||||
@@ -388,7 +385,8 @@ petsRouter.get("/:id/profile-summary", async (c) => {
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(appointments.petId, petId),
|
eq(appointments.petId, petId),
|
||||||
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed"))
|
or(eq(appointments.status, "scheduled"), eq(appointments.status, "confirmed")),
|
||||||
|
gte(appointments.startTime, new Date())
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(appointments.startTime)
|
.orderBy(appointments.startTime)
|
||||||
|
|||||||
Reference in New Issue
Block a user