Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Primary repo: `RJK134/Equismile`
- **Phases 40–44 are also merged to `main`** (PRs #248…#258). **Phase 40** (#248): My Day day-view appointment merge, batch "book all horses at a yard", BugBot credential guard. **Phase 41** (#256): §20 invoicing completion — **credit notes** (immutable, AR-netted, QR-less PDFs) + **invoice amendment** (amend-and-reissue with a `SUPERSEDED` supersede chain); migration `20260612220000`. **Phase 42** (#252): **cancellation gap-fill** (Jun-10 deferral D-2) — a cancelled/rescheduled CONFIRMED slot within 21d raises a task-centre task proposing the top-5 nearby due-recall candidates with `/appointments/new` deep-links; pure haversine ranking, no schema change, vet approves everything. **Phase 43** (#253): **`/admin/ops`** read-only operations roll-up (D-3) + KI-015 (checklist findings folded into `/reports`). **Phase 44** (#254): `Horse.bornAt` + Vetup DOB import (KI-008), unpositioned odontogram findings, `customer-upserted` n8n emit (KI-017, producer-side only), `n8n/13` Gmail IMAP intake; migration `20260612210000`. Production migrated for both new migrations. **➡️ Next-session handover + outstanding backlog: `docs/HANDOVER_2026-06-13.md`.**
- **✅ Production drift repair MERGED (PR #225).** Production was historically `prisma db push`-ed, so `Staff.locale` is physically NOT NULL while the schema says nullable → demo seed dies with P2011 after the five credential personas. The PR carries the alignment migration (`20260610091500…`, no-op on healthy DBs — verified by local repro) + a read-only `db-drift-report.yml`. **Applied on merge:** `migrate-production.yml` ran `prisma migrate deploy`; then **Seed demo database** (production) + the **Database drift report** confirmed clean — prod is migrated, seeded, and the recurring "click a client/horse → 500" class is structurally fixed. Ops workflows (all keyed on repo secret `PRODUCTION_DATABASE_URL`, set and verified): `migrate-production.yml`, `seed-demo-database.yml`, `resolve-migration-drift.yml` (`prisma migrate resolve --applied` — never hand-INSERT into `_prisma_migrations`), `db-drift-report.yml`.
- **Auth:** email + password (Auth.js Credentials, JWT); persona cards = active `Staff` rows with a `passwordHash`, so prod must be migrated + seeded or there are no logins. Temp password `EquiSmile2026!` (re-seeding resets it). Five credential logins: Kathelijne (admin/practitioner), Natacha + Margaux (vets, fr) and Patrick Vangele + Richard Knapp (system admins, en).
- Phase history 0–44: `docs/BUILD_PLAN.md`. Active issues/caveats: `docs/KNOWN_ISSUES.md` (KI-013…KI-027; KI-008/KI-015/KI-017 resolved in the Phase 41–44 wave). MVP scope: `docs/SCOPE_CLARIFICATIONS.md`. Contract `docs/CONTRACT_DRAFT_v3.md` (FH-ES-2026-005); costs `docs/RUNNING_COSTS.md`; support `docs/SUPPORT_MODEL.md`.
- **Phase 45 (feature wave) delivered on `feature/45-feature-wave-deferrals`** (this PR; merge left to Richard/Freddie): vet geolocation opt-in (KI-016 L — server-authoritative route origin + 30-min staleness), customer stable self-update (D-1 — vet pre-confirm-gated `StableUpdateRequest` → `/admin/stable-updates`, the app's only customer-initiated write), §16 correction-learning (vet draft edits → few-shot examples), and a `/reports` total-billed fix (exclude DRAFT/SUPERSEDED/CANCELLED). Additive migration `20260613120000_phase45_feature_wave`. Built via parallel git-worktree agents + an adversarial multi-agent review; two low findings accepted (KI-029 dedupe race, KI-030 correction-example language scoping).
- Phase history 0–45: `docs/BUILD_PLAN.md`. Active issues/caveats: `docs/KNOWN_ISSUES.md` (KI-013…KI-030; KI-008/KI-015/KI-017 resolved in the 41–44 wave; KI-016 L closed in Phase 45). MVP scope: `docs/SCOPE_CLARIFICATIONS.md`. Contract `docs/CONTRACT_DRAFT_v3.md` (FH-ES-2026-005); costs `docs/RUNNING_COSTS.md`; support `docs/SUPPORT_MODEL.md`.
- **CI — self-built BugBot now WORKS.** The repo "BugBot PR Review" GitHub Action (`anthropics/claude-code-action`) used to fail red with "Invalid API key"; the `ANTHROPIC_API_KEY` repo secret was set 2026-06-12 and it now reviews green end-to-end (verified live on #249 and the Phase 41–44 PRs). The substantive AI review remains the hosted **Cursor Bugbot** (`cursor[bot]`) — treat its Medium+ findings as real and fix before merge. Real merge gates: `check` (typecheck/lint/test/build), `docker`, `security`, GitGuardian. See KI-026/KI-027.
- **Latest session handover (2026-06-13):** `docs/HANDOVER_2026-06-13.md` — full state after the Phase 41–44 wave, the chosen next build (feature-wave deferral backlog), external blockers, env gotchas, and a copy-paste prompt for the next chat instance. (Prior: `docs/HANDOVER_2026-06-12.md`.)

Expand Down
132 changes: 132 additions & 0 deletions __tests__/unit/api/qualification-approve-draft.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* POST/DELETE /api/visit-requests/[id]/qualification/approve-draft tests.
*
* §16 correction-learning: the POST handler forwards an optional edited
* `{ body, originalDraft }` to the service so a vet's reworded draft can both
* be sent and logged as a training example. These tests pin the body
* passthrough, the no-body legacy path, the status → HTTP mapping and the
* NURSE auth gate. The service itself is mocked — its behaviour is covered by
* the service unit tests.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';

const requireRoleMock = vi.hoisted(() => vi.fn());
const approveQueuedDraftMock = vi.hoisted(() => vi.fn());
const dismissQueuedDraftMock = vi.hoisted(() => vi.fn());

vi.mock('@/auth', () => ({
auth: vi.fn(),
handlers: {},
signIn: vi.fn(),
signOut: vi.fn(),
}));

vi.mock('@/lib/auth/rbac', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/rbac')>('@/lib/auth/rbac');
return { ...actual, requireRole: requireRoleMock };
});

vi.mock('@/lib/services/qualification.service', () => ({
qualificationService: {
approveQueuedDraft: approveQueuedDraftMock,
dismissQueuedDraft: dismissQueuedDraftMock,
},
}));

const { AuthzError } = await import('@/lib/auth/rbac');
const { POST, DELETE } = await import(
'@/app/api/visit-requests/[id]/qualification/approve-draft/route'
);

const nurseActor = {
id: 'u1',
email: 'nurse@example.com',
githubLogin: null,
role: 'nurse' as const,
actorLabel: 'nurse@example.com',
};

const ctx = { params: Promise.resolve({ id: 'vr1' }) };

function buildPost(payload?: unknown): NextRequest {
return new NextRequest(
new URL('http://localhost:3000/api/visit-requests/vr1/qualification/approve-draft'),
{
method: 'POST',
...(payload !== undefined
? { body: JSON.stringify(payload), headers: { 'Content-Type': 'application/json' } }
: {}),
},
);
}

function buildDelete(): NextRequest {
return new NextRequest(
new URL('http://localhost:3000/api/visit-requests/vr1/qualification/approve-draft'),
{ method: 'DELETE' },
);
}

beforeEach(() => {
vi.clearAllMocks();
requireRoleMock.mockResolvedValue(nurseActor);
approveQueuedDraftMock.mockResolvedValue({ status: 'SENT', sent: true, channel: 'WHATSAPP' });
dismissQueuedDraftMock.mockResolvedValue({ status: 'DISMISSED' });
});

describe('POST /api/visit-requests/[id]/qualification/approve-draft', () => {
it('returns 403 when the session role is below NURSE', async () => {
requireRoleMock.mockRejectedValueOnce(new AuthzError('Forbidden — NURSE required', 403));
const res = await POST(buildPost(), ctx);
expect(res.status).toBe(403);
expect(approveQueuedDraftMock).not.toHaveBeenCalled();
});

it('forwards an edited body + originalDraft to the service', async () => {
const res = await POST(
buildPost({ body: 'Reworded question — EquiSmile', originalDraft: 'How many horses?' }),
ctx,
);
const data = await res.json();

expect(res.status).toBe(200);
expect(data).toEqual({ status: 'SENT', sent: true, channel: 'WHATSAPP' });
expect(approveQueuedDraftMock).toHaveBeenCalledWith('vr1', 'nurse@example.com', {
body: 'Reworded question — EquiSmile',
originalDraft: 'How many horses?',
});
});

it('approves with no edit (undefined) when the request carries no JSON body', async () => {
const res = await POST(buildPost(), ctx);

expect(res.status).toBe(200);
expect(approveQueuedDraftMock).toHaveBeenCalledWith('vr1', 'nurse@example.com', undefined);
});

it('maps NO_DRAFT to 409', async () => {
approveQueuedDraftMock.mockResolvedValue({ status: 'NO_DRAFT' });
const res = await POST(buildPost({ body: 'x' }), ctx);
const data = await res.json();
expect(res.status).toBe(409);
expect(data.status).toBe('NO_DRAFT');
});

it('maps NO_REQUEST to 404', async () => {
approveQueuedDraftMock.mockResolvedValue({ status: 'NO_REQUEST' });
const res = await POST(buildPost({ body: 'x' }), ctx);
expect(res.status).toBe(404);
});
});

describe('DELETE /api/visit-requests/[id]/qualification/approve-draft', () => {
it('dismisses the draft and returns 200', async () => {
const res = await DELETE(buildDelete(), ctx);
const data = await res.json();
expect(res.status).toBe(200);
expect(data.status).toBe('DISMISSED');
expect(dismissQueuedDraftMock).toHaveBeenCalledWith('vr1', 'nurse@example.com');
});
});
144 changes: 144 additions & 0 deletions __tests__/unit/api/staff-me-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';

// PATCH /api/staff/me/location lets the signed-in vet update their own live
// location-sharing state (KI-016 L). This suite locks the VET gate, the
// null-staff 404, Zod validation, and the two persist branches (enable+coords
// vs. disable). Mirrors the requireRole mock pattern from
// appointments/mutations-rbac.test.ts.

const requireRoleMock = vi.hoisted(() => vi.fn());
const getSessionStaffMock = vi.hoisted(() => vi.fn());
const staffUpdateMock = vi.hoisted(() => vi.fn());

vi.mock('@/auth', () => ({
auth: vi.fn(),
handlers: {},
signIn: vi.fn(),
signOut: vi.fn(),
}));

vi.mock('@/lib/auth/rbac', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/rbac')>('@/lib/auth/rbac');
return { ...actual, requireRole: requireRoleMock };
});

vi.mock('@/lib/auth/session-staff', () => ({
getSessionStaff: getSessionStaffMock,
}));

vi.mock('@/lib/prisma', () => ({
prisma: {
staff: {
update: staffUpdateMock,
},
},
}));

import { AuthzError } from '@/lib/auth/rbac';
import { PATCH } from '@/app/api/staff/me/location/route';

function signedInAs(role: 'admin' | 'vet' | 'nurse' | 'readonly') {
requireRoleMock.mockImplementation(async (required: 'admin' | 'vet' | 'nurse' | 'readonly') => {
const rank = { readonly: 1, nurse: 2, vet: 3, admin: 4 } as const;
if (rank[role] < rank[required]) {
throw new AuthzError(`Insufficient role: ${required} required`, 403);
}
return {
id: 'u1',
email: 'vet@example.com',
githubLogin: 'rjk134',
role,
actorLabel: 'rjk134',
};
});
}

function makeRequest(body: unknown): NextRequest {
return new NextRequest(new URL('/api/staff/me/location', 'http://localhost:3000'), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}

const staffRow = { id: 'staff-1', userId: 'u1', active: true };

describe('PATCH /api/staff/me/location', () => {
beforeEach(() => {
vi.clearAllMocks();
signedInAs('vet');
getSessionStaffMock.mockResolvedValue(staffRow);
staffUpdateMock.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
locationSharingEnabled: data.locationSharingEnabled ?? false,
lastKnownLat: data.lastKnownLat ?? null,
lastKnownLng: data.lastKnownLng ?? null,
lastLocationAt: data.lastLocationAt ?? null,
}));
});

it('rejects a sub-VET caller (nurse) with 403 before touching staff', async () => {
signedInAs('nurse');
const res = await PATCH(makeRequest({ enabled: true, lat: 51, lng: -2 }));
expect(res.status).toBe(403);
expect(getSessionStaffMock).not.toHaveBeenCalled();
expect(staffUpdateMock).not.toHaveBeenCalled();
});

it('rejects a readonly caller with 403', async () => {
signedInAs('readonly');
const res = await PATCH(makeRequest({ enabled: false }));
expect(res.status).toBe(403);
expect(staffUpdateMock).not.toHaveBeenCalled();
});

it('returns 404 when no staff profile is linked to the account', async () => {
getSessionStaffMock.mockResolvedValue(null);
const res = await PATCH(makeRequest({ enabled: true, lat: 51, lng: -2 }));
expect(res.status).toBe(404);
expect(staffUpdateMock).not.toHaveBeenCalled();
});

it('returns 400 on an out-of-range latitude', async () => {
const res = await PATCH(makeRequest({ enabled: true, lat: 91, lng: 0 }));
expect(res.status).toBe(400);
expect(staffUpdateMock).not.toHaveBeenCalled();
});

it('returns 400 on an out-of-range longitude', async () => {
const res = await PATCH(makeRequest({ enabled: true, lat: 0, lng: 181 }));
expect(res.status).toBe(400);
expect(staffUpdateMock).not.toHaveBeenCalled();
});

it('persists the fix and stamps the time when enabling with coordinates', async () => {
const res = await PATCH(makeRequest({ enabled: true, lat: 51.4545, lng: -2.5879 }));
expect(res.status).toBe(200);
expect(staffUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'staff-1' },
data: expect.objectContaining({
locationSharingEnabled: true,
lastKnownLat: 51.4545,
lastKnownLng: -2.5879,
lastLocationAt: expect.any(Date),
}),
}),
);
});

it('flips sharing off without writing coordinates when disabling', async () => {
const res = await PATCH(makeRequest({ enabled: false }));
expect(res.status).toBe(200);
expect(staffUpdateMock).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'staff-1' },
data: { locationSharingEnabled: false },
}),
);
// No fix is written on the disable path.
const callArg = staffUpdateMock.mock.calls[0][0] as { data: Record<string, unknown> };
expect(callArg.data).not.toHaveProperty('lastKnownLat');
expect(callArg.data).not.toHaveProperty('lastLocationAt');
});
});
9 changes: 8 additions & 1 deletion __tests__/unit/services/auto-triage.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const { mockPrisma, raiseUrgentAlertMock, kickOffMock } = vi.hoisted(() => ({
const { mockPrisma, raiseUrgentAlertMock, kickOffMock, createFromDetectionMock } = vi.hoisted(() => ({
kickOffMock: vi.fn().mockResolvedValue({ outcome: 'DRAFT_QUEUED', resolved: [], outstanding: [] }),
createFromDetectionMock: vi.fn().mockResolvedValue({ id: 'sur-1', status: 'PENDING' }),
mockPrisma: {
visitRequest: {
findUnique: vi.fn(),
Expand All @@ -28,6 +29,12 @@ vi.mock('@/lib/services/qualification.service', () => ({
vi.mock('@/lib/services/emergency.service', () => ({
emergencyService: { raiseUrgentAlert: raiseUrgentAlertMock },
}));
// Item 2 (D-1) — the stable-change hook also raises a structured
// StableUpdateRequest. Stub it here: this suite pins the LOCATION_CHECK
// task behaviour; the request-creation path has its own dedicated test.
vi.mock('@/lib/services/stable-update.service', () => ({
stableUpdateService: { createFromDetection: createFromDetectionMock },
}));

import { autoTriageService } from '@/lib/services/auto-triage.service';

Expand Down
Loading
Loading