00619a07e0a12488fde24b7ebfa9307122d40f6c
10 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ad1f32eb8f |
feat(auth): replace OIDC/jose with Better-Auth (#136)
* feat(db): add Better-Auth schema tables (GRO-118) Add user, session, account, and verification tables required by Better-Auth's Drizzle adapter. Add nullable userId FK on staff to link business identity to auth identity. Fix test fixtures and factory to include the new column. Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(api): mount Better-Auth handler at /api/auth/** (GRO-118) - Import toNodeHandler from better-auth/node and auth from ./lib/auth.js - Mount Better-Auth HTTP handler before auth middleware block - Handles OAuth callbacks, sign-in/sign-out, session management - Supports GET/POST/PUT/PATCH/DELETE/OPTIONS methods Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(api): replace JWT auth with Better-Auth session validation (GRO-118) - Replace jose/jwtVerify with auth.api.getSession() - Session token validated via cookie/header, DB-backed - jwtPayload.sub now = Better-Auth user ID (not OIDC sub) - Dev mode bypass preserved; production guard against AUTH_DISABLED preserved - rbac.ts and tests updated in subsequent tasks Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(api): update resolveStaffMiddleware for Better-Auth userId (GRO-118) - Remove JwtPayload import; use inline type in AppEnv - Production and dev mode lookups now use staff.userId (not oidcSub) - Backward compat: jwtPayload.sub now = Better-Auth user ID Co-Authored-By: Paperclip <noreply@paperclip.ing> * chore(api): remove jose and openid-client deps (GRO-118) - Remove unused jose and openid-client packages - Regenerate pnpm lockfile - Pre-existing Zod type errors resolved (1 remaining: JwtPayload in test) Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(api): remove stale JwtPayload import from impersonation test (GRO-118) auth.ts no longer exports JwtPayload — replace with inline type. Co-Authored-By: Paperclip <noreply@paperclip.ing> * test(api): update RBAC tests for Better-Auth userId (GRO-128) - Add userId field to mock staff records (MANAGER, RECEPTIONIST, GROOMER) - Update jwtPayload.sub to use userId instead of oidcSub in test helpers - Update dev mode X-Dev-User-Id header to use userId Co-Authored-By: Paperclip <noreply@paperclip.ing> * chore(api): upgrade zod to v4 with v3 compat layer (GRO-131) - Bump zod from ^3.24.1 to ^4.3.6 - Bump @hono/zod-validator from ^0.4.3 to ^0.7.6 - Update all 12 route files to import from "zod/v3" compat layer Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(api): add Better-Auth configuration (GRO-118) Exports the better-auth() instance configured with: - Drizzle PG adapter - genericOAuth plugin for Authentik OIDC - 7-day session with 5-min cookie cache Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(web): install Better-Auth client and create config (GRO-118) - Add better-auth to apps/web/package.json dependencies - Create apps/web/src/lib/auth-client.ts with createAuthClient config - Export signIn, signOut, useSession from the client - Add vite-env.d.ts for Vite client types Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(web): use Better-Auth session state in App.tsx (GRO-126) Add useSession hook to check Better-Auth session for production auth. Redirect to Authentik sign-in when no session in production mode. Dev mode flow (DevLoginSelector) preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(web): scope devFetch interceptor to dev mode only (GRO-127) * fix(api): validate BETTER_AUTH_SECRET and fix lockfile specifier (GRO-118) - Add startup validation for BETTER_AUTH_SECRET when auth is enabled - Fix pnpm-lock.yaml typescript specifier mismatch (^5.9.3 → ^5.7.3) Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(web): mock authDisabled=true in App.test.tsx to fix CI failures App.test.tsx "App navigation" tests were failing because the beforeEach set authDisabled=false (production mode), which triggers the Better Auth useSession() path. Since useSession() was not mocked in tests, the component rendered null instead of the admin nav. Now uses authDisabled=true + dev user in localStorage for those tests, bypassing the Better Auth dependency while still testing the nav render. Also removes duplicate App.test.js (compiled artifact). Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(e2e): set authDisabled=true in fixtures to bypass Better Auth The App.tsx production auth path calls signIn.social() when authDisabled=false, causing E2E tests to render blank. The fixtures must mock authDisabled=true so the dev login selector is used instead. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(e2e): add dev/config, dev/users, and branding mocks to navigation.spec.ts Playwright matches routes in last-registered-first-served order, so the catch-all /api/** handler was overwriting the authDisabled: true fixture. Added specific handlers before the catch-all to ensure auth config, user list, and branding responses are properly shaped. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(web): gate DevLoginSelector on API authDisabled, not import.meta.env.DEV Move the DevLoginSelector rendering check from import.meta.env.DEV to the API-driven authDisabled state, after the loading guard. Simplify the redirect condition to remove the now-redundant pathname exception. Fixes E2E login tests that were failing because DevLoginSelector was never rendered in Docker production builds where import.meta.env.DEV is false. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(db): add missing migration journal entries 0012-0017 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(web): import App.tsx (not App.js) in App.test.tsx (#137) * fix(web): mock /api/auth/get-session in Dev login selector test The "redirects to /login when auth is disabled and no user selected" test fails because useSession() from better-auth/react calls /api/auth/get-session which wasn't mocked, causing sessionLoading to stay true indefinitely. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(web): import App.tsx (not App.js) in test to get authDisabled bypass The Dev login selector test was importing the compiled App.js instead of the source App.tsx. App.js has different logic (uses import.meta.env.DEV instead of API-based authDisabled) and doesn't implement the sessionLoading bypass needed for tests to pass. Also applied the rawSession/rawSessionLoading refactor in App.tsx that bypasses useSession result when authDisabled=true. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(web): use extensionless import for App in test The `.tsx` extension in the import path is not allowed without `allowImportingTsExtensions` (TS5097). Use extensionless `../App` which resolves correctly via moduleResolution: "bundler". Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing> * fix(auth): dev login resolve staff by id, not userId Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(rbac): fallback lookup for staff records predating Better-Auth userId (#140) GRO-153: /api/staff returned 403 for all staff because resolveStaffMiddleware looked up by staff.userId (Better-Auth ID) but dev login sent staff.id (PK), and existing staff records had userId=NULL. Changes: - resolveStaffMiddleware: try userId first, fall back to staff.id (dev mode) - resolveStaffMiddleware: try userId first, fall back to oidcSub (production) - GET /api/dev/users: include userId field for DevLoginSelector - DevLoginSelector: send userId (not staff.id) as X-Dev-User-Id - Migration 0018: backfill userId for known demo staff Co-authored-by: groombook-engineer[bot] <groombook-engineer@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Barkley Trimsworth <barkley@groombook.farh.net> * fix(rbac): allow all staff roles to READ /api/staff GRO-156 follow-up: RBAC middleware was blocking groomer/receptionist from GET /api/staff. The QA review found 403 with "role groomer is not permitted" after PR #140 deployment. Fix: split the /staff/* guard — GET requests allow all roles (groomer, receptionist, manager); write operations remain manager-only. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: groombook-engineer[bot] <269742240+groombook-engineer[bot]@users.noreply.github.com> Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing> Co-authored-by: groombook-engineer[bot] <groombook-engineer@users.noreply.github.com> Co-authored-by: Barkley Trimsworth <barkley@groombook.farh.net> |
||
|
|
9eb0c3d151 |
fix(gro66): E2E selector fix + groomer isolation + portal confirm/cancel
* Implement confirm/cancel in customer portal (GRO-50) Backend: - Add POST /api/portal/appointments/:id/confirm endpoint - Validates impersonation session auth and ownership - Rejects past/in-progress, non-pending, or already-cancelled/completed - Sets confirmationStatus="confirmed", confirmedAt, updatedAt - Add POST /api/portal/appointments/:id/cancel endpoint - Same auth/ownership pattern - Rejects past/in-progress or already-cancelled/completed - Sets status="cancelled", confirmationStatus="cancelled", cancelledAt, updatedAt Frontend (Appointments.tsx): - Add confirmationStatus field to Appointment type and mock data - Add ConfirmationSection component: shows status badge + confirm button - Add CancelAppointmentButton: wires to cancel API with loading/error state - Wire existing Cancel button to CancelAppointmentButton - Show confirmation status badge in expanded view for upcoming appointments Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2) Filter query results at the route handler level when staff role is groomer: - GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer - GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather) - GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery) - GET /api/clients/🆔 403 if no appointment linkage - GET /api/pets: Pets owned by groomer-linked clients (via exists subquery) - GET /api/pets/:petId: 403 if no appointment linkage Managers and receptionists: no change. Added exists to @groombook/db exports (was missing from re-export). Added groomerIsolation unit tests for role guard and filter logic. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-50): add portal confirm/cancel tests and fix ConfirmationSection state - Add test coverage for POST /portal/appointments/:id/confirm endpoint - Add test coverage for POST /portal/appointments/:id/cancel endpoint - Fix ConfirmationSection not updating local status after successful confirm - Remove unused onCancel prop from ConfirmationSection call site - Fix Appointments.test.tsx missing confirmationStatus field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(gro-50): add ConfirmationSection UI component tests Add tests for the ConfirmationSection component: - Renders correct badge for each confirmationStatus state - Shows/hides Confirm button based on status - Calls confirm API with correct headers - Handles sessionId null case - Shows error messages for 401/403/422 responses - Shows loading state while confirming - Shows success message briefly after confirm - Does not call API if user cancels confirm dialog Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-48): address QA review feedback — staffRow?.role and portal TS guards - appointments.ts: use staffRow?.role (consistent with clients.ts/pets.ts) to handle undefined staff context safely - portal.ts: add null guards on .returning() results for confirm and cancel endpoints (TS18048: 'updated' is possibly undefined) - All 188 tests passing; TypeScript typecheck clean Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro66): use specific selector for banner visibility assertion Replace ambiguous `getByText("STAFF VIEW")` that matched both the ImpersonationBanner and the CustomerPortal watermark with a precise `getByTestId("impersonation-banner")` selector to eliminate strict mode violations. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-66): add missing afterEach to vitest import Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-48): add icalToken to MANAGER mock after rebase After rebasing onto origin/main (which added icalToken to the staff schema via GRO-107), the MANAGER mock in groomerIsolation.test.ts was missing the new required field. Added icalToken: null to the MANAGER constant. factories.ts is clean (no duplicate icalToken after rebase). Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(gro-47): add non-null assertions on Drizzle RETURNING results Drizzle's update().returning() types the array element as T | undefined. After the if (!appt) guard, updated is still typed as possibly undefined because RETURNING can succeed with no rows. Add ! assertions since we already guard with the existence check. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Flea Flicker <fleaflicker@groombook.ai> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing> |
||
|
|
e3220af9ce |
fix(gro-38): prod/demo auth and API-based seed (#117)
Closes GRO-38. Adds POST /api/admin/seed (manager-only, gated by SEED_KNOWN_USERS_ONLY) and separates dev vs prod seeding paths. Reviewed and approved by CTO and QA. Co-Authored-By: Paperclip <noreply@paperclip.ing> |
||
|
|
d1ab91adfa |
feat: appointment confirmation and cancellation (GH #98, GRO-153)
Add customer confirmation/cancellation flow for appointments: - DB migration (0013): add confirmation_status, confirmed_at, cancelled_at, confirmation_token to appointments table with index on token column - schema.ts + factories.ts + types: expose new columns and ConfirmationStatus type - GET /api/book/confirm/:token — tokenized confirm via email link (redirects) - GET /api/book/cancel/:token — tokenized cancel via email link, single-use token - POST /api/appointments/:id/confirm — portal/staff confirm endpoint - POST /api/appointments/:id/cancel — portal/staff cancel endpoint - Reminder emails now include Confirm/Cancel CTA buttons with tokenized links - Reminder service generates confirmation token if missing before sending - Staff calendar shows confirmation status indicator on appointment cards and in the detail modal (confirmed ✓ / customer cancelled ✗) - /booking/confirmed, /booking/cancelled, /booking/error redirect pages - 23 new unit tests covering all new endpoints and edge cases Co-Authored-By: Paperclip <noreply@paperclip.ing> |
||
|
|
4ab5597fd5 |
feat: tip and payment splitting between staff roles (#34)
* feat: multi-groomer calendar view with per-groomer filtering Add groomer view mode to the appointments calendar: - Toggle between "Status" (existing) and "Groomer" color coding - Per-groomer visibility toggles with color-coded buttons - Appointments colored by assigned groomer in groomer view - Groomer name shown on appointment blocks in groomer view - Unassigned appointments shown in neutral gray Satisfies groombook/groombook#11 requirements for side-by-side/unified groomer schedule visibility and per-groomer filter/toggle. Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat: tip and payment splitting between staff roles Implements groombook/groombook#12 — track which staff worked on each pet and calculate tip distribution based on who was involved. Changes: - DB: Add bather_staff_id to appointments (optional secondary staff) - DB: Add invoice_tip_splits table (per-staff tip share ledger) - API: appointments POST/PATCH accept batherStaffId - API: GET /invoices/:id now includes tipSplits[] - API: POST /invoices/:id/tip-splits — saves tip distribution - API: GET /reports/tip-splits — payroll summary of tip earnings - Frontend: Bather/Assistant select on New Appointment form - Frontend: Tip Distribution section in Invoice Detail modal - Auto-populates 70%/30% split when bather is assigned - Editable percentages before payment; saved on Mark as Paid - Displays recorded splits on paid invoices Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: remove unused staff import from invoices route Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing> |
||
|
|
addcefe70b |
feat: automated appointment reminders via email (GRO-23) (#29)
Implements Phase 1 of groombook/groombook#4 — automated email reminders for upcoming appointments, with booking confirmations sent immediately on creation. - **DB**: new `reminder_logs` table tracks sent reminders per appointment (unique on appointmentId+type prevents duplicates); `clients` gains `email_opt_out` boolean (migration 0004_reminder_logs) - **Email service**: `apps/api/src/services/email.ts` — nodemailer SMTP transport (disabled when SMTP_HOST is unset, so self-hosted installs without email config are unaffected); confirmation and reminder email templates included - **Reminder scheduler**: `apps/api/src/services/reminders.ts` — node-cron job runs every minute, checks for appointments in the upcoming reminder windows (default: 24 h and 2 h), sends emails for opted-in clients, and records sends in reminder_logs (idempotent via ON CONFLICT DO NOTHING) - **Confirmation email**: sent fire-and-forget after successful appointment creation (both single and recurring); never blocks the API response - **Config**: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_FROM, REMINDER_HOURS_EARLY, REMINDER_HOURS_LATE env vars documented in .env.example; all optional — feature is silently disabled without them - **Types**: Client.emailOptOut field added to shared types package Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing> |
||
|
|
e7cf185d8c |
feat: recurring appointments with cascading change propagation (#28)
* feat: recurring appointments with cascading change propagation Implements GitHub issue #9 — recurring appointment scheduling with configurable frequency and cascade edit/cancel options. Changes: - DB: add `recurring_series` table (frequency_weeks) and series_id / series_index columns on appointments (migration 0003) - API POST /appointments: accepts optional `recurrence` object (frequencyWeeks + count) that creates a full series in one transaction - API PATCH /appointments/🆔 new `cascadeMode` field (this_only | this_and_future | all) applies time-delta shifts and field updates across the series - API DELETE /appointments/🆔 new `?cascade=` query param cancels this_only / this_and_future / all series members - Frontend: booking form gains a "Recurring appointment" checkbox with frequency and count pickers; calendar chips show a ↻ recurring label; detail modal shows "Recurring series" badge and a cascade-delete radio picker for series appointments Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: resolve TypeScript errors in recurring appointments route Guard against possibly-undefined results from Drizzle .returning() destructuring — use indexed access + explicit null checks instead of array destructuring for the recurring_series insert, and add an early throw when the series or first appointment row is missing. Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing> |
||
|
|
43e50255ec |
fix: appointment conflict detection, soft-delete, and auth guardrail (#18-22)
Fixes five bugs flagged in CEO code review (GitHub issues #18–22): - #18: Wrap conflict check + insert/update in a DB transaction to prevent double-booking race conditions under concurrent load. - #19: PATCH conflict detection now falls back to the existing appointment's staffId when staffId is omitted from the request body, so rescheduling always checks for conflicts. - #20: DELETE endpoint now soft-deletes (status = 'cancelled') instead of hard-deleting, preserving audit trail and financial records. - #21: Staff DELETE checks for existing non-cancelled appointments before deleting and returns 409 if any are found, preventing orphaned references. - #22: AUTH_DISABLED=true now logs a startup warning in development and calls process.exit(1) in production, preventing accidental auth bypass in deployed environments. Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing> |
||
|
|
4f92b8bffb |
feat: appointment scheduling, client/pet/service/staff CRUD UI
* feat: appointment scheduling, client/pet/service/staff CRUD UI - Weekly calendar view with navigation, color-coded by status - Booking form with client→pet→service→staff→date/time flow - Double-booking conflict detection on POST/PATCH appointments - DELETE /api/appointments endpoint - Staff API route (/api/staff) with full CRUD - Clients page: searchable list, create/edit clients, add/edit pets - Services page: table with create/edit/toggle-active - Staff page: table with create/edit/toggle-active - Nav bar with active-link highlighting, Staff link added Resolves GitHub groombook/groombook#1, #2, #8 Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: remove unused import, fix useCallback deps - Remove unused `or` import from drizzle-orm in appointments route - Compute week end directly in loadAppointments callback to avoid exhaustive-deps lint warning (weekEnd derived from weekStart) Co-Authored-By: Paperclip <noreply@paperclip.ing> * chore: add pnpm lockfile Required for CI --frozen-lockfile installs. Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix: resolve all typecheck, lint, and test failures - Add @types/node to packages/db devDependencies (typecheck was missing process) - Re-export drizzle-orm helpers (eq, gte, etc.) from @groombook/db to avoid duplicate-instance type conflicts; remove drizzle-orm direct dep from API - Add @hono/zod-validator and jose as direct API dependencies - Merge duplicate @groombook/db imports in all route files - Fix noUncheckedIndexedAccess errors: appointments PATCH, web calendar grid - Fix weightKg/dateOfBirth type conversion in pets route (numeric→string, string→Date) - Add eslint.config.js for API and web (ESLint 9 flat config format) - Add vitest.config.ts with passWithNoTests for API and web Co-Authored-By: Paperclip <noreply@paperclip.ing> --------- Co-authored-by: Groom Book CTO <cto@groombook.app> Co-authored-by: Paperclip <noreply@paperclip.ing> |
||
|
|
a36436d128 |
Bootstrap monorepo: Hono API, React PWA, Drizzle DB, CI/CD
Sets up the initial project structure for groombook/groombook: - pnpm monorepo with apps/api (Hono + TypeScript), apps/web (React + Vite + PWA), packages/db (Drizzle ORM), packages/types (shared types) - Core DB schema: clients, pets, services, appointments, staff with CNPG-compatible Postgres - REST API routes for clients, pets, services, appointments with Zod validation - OIDC auth middleware for Authentik integration - React PWA with vite-plugin-pwa, service worker, offline caching, installable manifest - GitHub Actions CI: lint, typecheck, test, build, Docker image build (groombook-runners) - Dockerfiles for API (Node.js) and Web (nginx) - docker-compose.yml for local development Co-Authored-By: Paperclip <noreply@paperclip.ing> |