From 17eee5b93298fc537cb4d46b25890fa2190d7d25 Mon Sep 17 00:00:00 2001
From: Stefan Lazic
Date: Sat, 17 Oct 2026 22:29:00 +0100
Subject: [PATCH] Updates 1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The session built:
Appointments page: a single list, newest first.
Block Time: edit and delete.
Customer CSV export: with barber notes excluded.
Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs.
It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
---
docs/phase-4-summary.md | 6 +-
docs/phase-6-summary.md | 4 +-
e2e/customer-journeys.spec.ts | 46 +--
manager/.env.example | 16 +
manager/Dockerfile | 5 +
manager/README.md | 51 ++-
manager/src/__tests__/proxy.test.ts | 33 +-
manager/src/app/api/__tests__/routes.test.ts | 114 ++++++
.../app/api/backups/[slug]/[file]/route.ts | 36 ++
manager/src/app/api/backups/upload/route.ts | 71 ++++
manager/src/app/api/jobs/[id]/route.ts | 17 +
manager/src/app/dashboard/[slug]/actions.ts | 35 +-
.../app/dashboard/[slug]/backups/actions.ts | 82 +++++
.../[slug]/backups/backups-panel.tsx | 268 +++++++++++++++
.../src/app/dashboard/[slug]/backups/page.tsx | 66 ++++
.../app/dashboard/[slug]/business-actions.tsx | 67 +++-
.../app/dashboard/[slug]/delete/actions.ts | 28 +-
.../[slug]/delete/delete-business-form.tsx | 26 +-
manager/src/app/dashboard/[slug]/page.tsx | 11 +-
manager/src/app/dashboard/job-progress.tsx | 153 +++++++++
manager/src/app/dashboard/layout.tsx | 3 +
.../src/app/dashboard/passphrase-notice.tsx | 33 ++
manager/src/app/dashboard/restore/actions.ts | 104 ++++++
manager/src/app/dashboard/restore/page.tsx | 45 +++
.../app/dashboard/restore/restore-wizard.tsx | 296 ++++++++++++++++
manager/src/app/dashboard/settings/actions.ts | 52 +++
.../settings/operator-settings-form.tsx | 167 +++++++++
manager/src/app/dashboard/settings/page.tsx | 15 +-
manager/src/instrumentation.ts | 21 ++
.../src/lib/__tests__/backup-manifest.test.ts | 83 +++++
.../src/lib/__tests__/backup-settings.test.ts | 118 +++++++
.../src/lib/__tests__/backup-store.test.ts | 106 ++++++
manager/src/lib/__tests__/backup.test.ts | 201 +++++++++++
.../src/lib/__tests__/docker-commands.test.ts | 124 +++++++
.../src/lib/__tests__/health-watcher.test.ts | 245 +++++++++++++
manager/src/lib/__tests__/jobs.test.ts | 141 ++++++++
manager/src/lib/__tests__/locks.test.ts | 83 +++++
manager/src/lib/__tests__/operations.test.ts | 105 ++++++
manager/src/lib/__tests__/process.test.ts | 71 ++++
manager/src/lib/__tests__/restore.test.ts | 225 ++++++++++++
manager/src/lib/__tests__/scheduler.test.ts | 161 +++++++++
manager/src/lib/backup-manifest.ts | 147 ++++++++
manager/src/lib/backup-settings.ts | 173 ++++++++++
manager/src/lib/backup-store.ts | 136 ++++++++
manager/src/lib/backup.ts | 196 +++++++++++
manager/src/lib/docker-commands.ts | 127 +++++++
manager/src/lib/docker.ts | 26 ++
manager/src/lib/global-settings.ts | 123 ++++++-
manager/src/lib/health-watcher.ts | 269 +++++++++++++++
manager/src/lib/health.ts | 158 +++++++++
manager/src/lib/job-state.ts | 9 +
manager/src/lib/jobs.ts | 270 +++++++++++++++
manager/src/lib/json-file.ts | 20 ++
manager/src/lib/locks.ts | 121 +++++++
manager/src/lib/manager-paths.ts | 45 +++
manager/src/lib/operations.ts | 160 +++++++++
manager/src/lib/operator-email.ts | 28 ++
manager/src/lib/process.ts | 178 ++++++++++
manager/src/lib/provisioning.ts | 21 +-
manager/src/lib/restore.ts | 209 +++++++++++
manager/src/lib/scheduler-start.ts | 112 ++++++
manager/src/lib/scheduler-status.ts | 21 ++
manager/src/lib/scheduler-tasks.ts | 152 ++++++++
manager/src/lib/scheduler.ts | 176 ++++++++++
manager/src/lib/sevenzip.ts | 56 +++
manager/src/lib/singleton.ts | 34 ++
manager/src/lib/slug.ts | 2 +-
manager/src/lib/stacks.ts | 21 ++
manager/src/lib/test-email.ts | 38 ++
manager/src/proxy.ts | 6 +-
src/app/(customer)/appointments/page.tsx | 110 +++---
.../(owner)/owner/team/[id]/time-off/page.tsx | 6 +-
src/app/(staff)/staff/clients/[id]/page.tsx | 23 +-
.../(staff)/staff/settings/time-off/page.tsx | 7 +-
src/app/api/customers/[id]/export/route.ts | 44 +++
.../booking/actions/customer-appointments.ts | 12 +-
.../breaks-time-off.integration.test.ts | 93 ++++-
src/features/owner/actions/time-off.ts | 7 +-
src/features/staff/actions/time-off.ts | 9 +-
.../staff/components/time-off-form-sheet.tsx | 74 +++-
.../staff/components/time-off-list.tsx | 11 +-
src/lib/csv/__tests__/write-csv.test.ts | 67 ++++
src/lib/csv/write-csv.ts | 38 ++
src/lib/time/__tests__/business-time.test.ts | 65 ++++
src/lib/time/business-time.ts | 13 +-
.../__tests__/customer-export-csv.test.ts | 62 ++++
...ustomer-export.service.integration.test.ts | 325 ++++++++++++++++++
src/services/customer/customer-export-csv.ts | 47 +++
.../customer/customer-export.service.ts | 271 +++++++++++++++
.../clock-change.integration.test.ts | 183 ++++++++++
.../guest-access.integration.test.ts | 48 +++
.../scheduling/appointment.service.ts | 7 +-
.../scheduling/availability.service.ts | 7 +-
src/services/staff/staff.service.ts | 2 +
stacks/infra/scripts/process-cron.sh | 6 +
95 files changed, 7955 insertions(+), 150 deletions(-)
create mode 100644 manager/src/app/api/__tests__/routes.test.ts
create mode 100644 manager/src/app/api/backups/[slug]/[file]/route.ts
create mode 100644 manager/src/app/api/backups/upload/route.ts
create mode 100644 manager/src/app/api/jobs/[id]/route.ts
create mode 100644 manager/src/app/dashboard/[slug]/backups/actions.ts
create mode 100644 manager/src/app/dashboard/[slug]/backups/backups-panel.tsx
create mode 100644 manager/src/app/dashboard/[slug]/backups/page.tsx
create mode 100644 manager/src/app/dashboard/job-progress.tsx
create mode 100644 manager/src/app/dashboard/passphrase-notice.tsx
create mode 100644 manager/src/app/dashboard/restore/actions.ts
create mode 100644 manager/src/app/dashboard/restore/page.tsx
create mode 100644 manager/src/app/dashboard/restore/restore-wizard.tsx
create mode 100644 manager/src/app/dashboard/settings/operator-settings-form.tsx
create mode 100644 manager/src/instrumentation.ts
create mode 100644 manager/src/lib/__tests__/backup-manifest.test.ts
create mode 100644 manager/src/lib/__tests__/backup-settings.test.ts
create mode 100644 manager/src/lib/__tests__/backup-store.test.ts
create mode 100644 manager/src/lib/__tests__/backup.test.ts
create mode 100644 manager/src/lib/__tests__/docker-commands.test.ts
create mode 100644 manager/src/lib/__tests__/health-watcher.test.ts
create mode 100644 manager/src/lib/__tests__/jobs.test.ts
create mode 100644 manager/src/lib/__tests__/locks.test.ts
create mode 100644 manager/src/lib/__tests__/operations.test.ts
create mode 100644 manager/src/lib/__tests__/process.test.ts
create mode 100644 manager/src/lib/__tests__/restore.test.ts
create mode 100644 manager/src/lib/__tests__/scheduler.test.ts
create mode 100644 manager/src/lib/backup-manifest.ts
create mode 100644 manager/src/lib/backup-settings.ts
create mode 100644 manager/src/lib/backup-store.ts
create mode 100644 manager/src/lib/backup.ts
create mode 100644 manager/src/lib/docker-commands.ts
create mode 100644 manager/src/lib/health-watcher.ts
create mode 100644 manager/src/lib/health.ts
create mode 100644 manager/src/lib/job-state.ts
create mode 100644 manager/src/lib/jobs.ts
create mode 100644 manager/src/lib/json-file.ts
create mode 100644 manager/src/lib/locks.ts
create mode 100644 manager/src/lib/manager-paths.ts
create mode 100644 manager/src/lib/operations.ts
create mode 100644 manager/src/lib/operator-email.ts
create mode 100644 manager/src/lib/process.ts
create mode 100644 manager/src/lib/restore.ts
create mode 100644 manager/src/lib/scheduler-start.ts
create mode 100644 manager/src/lib/scheduler-status.ts
create mode 100644 manager/src/lib/scheduler-tasks.ts
create mode 100644 manager/src/lib/scheduler.ts
create mode 100644 manager/src/lib/sevenzip.ts
create mode 100644 manager/src/lib/singleton.ts
create mode 100644 src/app/api/customers/[id]/export/route.ts
create mode 100644 src/lib/csv/__tests__/write-csv.test.ts
create mode 100644 src/lib/csv/write-csv.ts
create mode 100644 src/services/customer/__tests__/customer-export-csv.test.ts
create mode 100644 src/services/customer/__tests__/customer-export.service.integration.test.ts
create mode 100644 src/services/customer/customer-export-csv.ts
create mode 100644 src/services/customer/customer-export.service.ts
create mode 100644 src/services/scheduling/__tests__/clock-change.integration.test.ts
diff --git a/docs/phase-4-summary.md b/docs/phase-4-summary.md
index bd8c04a..db02b77 100644
--- a/docs/phase-4-summary.md
+++ b/docs/phase-4-summary.md
@@ -69,7 +69,7 @@ Documented in full in `src/services/customer/customer.service.ts`; summary:
2. A guest caller is matched against an existing profile only if **both** email (case-insensitive, normalised in application code — not Prisma's `mode: "insensitive"`, which is Postgres/Mongo-only and would break on a MySQL swap) **and** phone match exactly, against `isGuest = true AND userId IS NULL` profiles only.
3. A guest is never matched against a registered customer's profile, even on an exact email match — that would attach a guest's booking to a real account without verification.
4. No match → a new guest profile is created. Existing-profile fields are never overwritten on a match.
-5. **Guest appointment access is "order tracking" by unguessable id**, not by verified identity — no magic-link/OTP system was built this phase. This is a deliberate, documented scope limitation (see §12), not an oversight.
+5. ~~**Guest appointment access is "order tracking" by unguessable id**, not by verified identity~~ — *superseded in Phase 7.* A guest appointment now needs a single-purpose access token in the link (`?token=…`), checked by `assertCanAccessAppointment` against `GuestAppointmentAccessToken` (see `src/services/scheduling/guest-access.service.ts` and `docs/notifications.md`). The appointment id alone is rejected; the page returns "not found" and the actions refuse. This is covered by `src/services/scheduling/__tests__/guest-access.integration.test.ts` ("an appointment id alone is rejected"). What was originally described here — no magic-link/OTP flow in Phase 4 — is still true of the phase as shipped.
## 8. Security / ownership rules
@@ -117,11 +117,11 @@ Found and fixed while verifying, not left in place:
- **A silent test-cleanup leak** in the Phase 3 integration fixtures: `cleanupTestBusiness` tried to delete the test `Business` first, but `Appointment.staff` is `onDelete: Restrict` (a deliberate production safeguard), which blocked the cascade to `StaffProfile` for any test that had actually booked something — the failure was swallowed by a bare `.catch(() => {})`. This had been silently leaking a "Test Barbershop" row per affected test run since Phase 3; it only surfaced now because a manual verification pass queried `getPrimaryBusiness()` against a database that had just run the integration suite and picked up a leaked row instead of the real seed. Fixed by deleting the business's appointments before the business itself.
- **Desktop layout was effectively unstyled for the "browsing" screens** (Home, Appointments, Settings, and the bottom nav) — content simply didn't reflow or cap its width above mobile size, leaving a large empty right-hand column rather than anything intentional. Applied a **baseline** fix (a centered `max-w-2xl` content column, matching the pattern the booking flow's individual steps already used) across Home, Appointments, Settings, the shared `BottomNav`, `BarberDateTimePicker`, and `DetailsForm`. **This is not a full desktop redesign** — no side-by-side / grid layouts were built for browsing screens, only a consistent centered treatment so nothing looks broken. A genuine desktop-specific layout (e.g. a wider services grid, a split-pane appointment list) is a reasonable Phase 5+ item if desktop usage turns out to matter.
- **Reschedule's route was moved out of the `(customer)` route group** (`(customer)/appointments/[id]/reschedule` → top-level `appointments/[id]/reschedule`) after discovering the shared picker's own fixed bottom action bar was being covered by the persistent bottom nav on that route, blocking the "Confirm Reschedule" button. Matches the same reasoning `/book`'s layout already documents.
-- **Guest appointment access remains order-tracking-by-id only** (§7, point 5) — not a bug, but worth restating as the clearest remaining security-adjacent scope boundary: a guest with the appointment id can view/reschedule/cancel it without further verification. Fine for this phase's threat model (ids are unguessable `cuid`s, never enumerable), but a real magic-link/OTP flow would close this gap properly.
+- ~~**Guest appointment access remains order-tracking-by-id only**~~ — *closed in Phase 7.* A guest with only the appointment id can no longer view, reschedule, cancel or export the booking; they need the token from their confirmation page or email link (§7, point 5). Still open and deliberate: the token is a bearer link (anyone holding it has access until it expires, 90 days after the appointment ends) rather than a verified identity such as an OTP.
## 13. Before Phase 5
-- Decide whether guest-booking access needs stronger verification (magic link / OTP) before this becomes a compliance concern, or whether "unguessable id" remains acceptable for the foreseeable future.
+- ~~Decide whether guest-booking access needs stronger verification~~ — answered in Phase 7 with per-appointment access tokens; a magic-link/OTP step on top remains optional.
- If desktop usage is expected to matter, budget real design time for Home/Appointments rather than the baseline centering applied here.
- The `e2e/` suite and `test:integration` script both assume a disposable Postgres the caller provides — there's no CI wiring yet to spin one up automatically; that's worth doing before this becomes the main safety net.
- Loyalty UI, staff/owner-facing screens, notifications, and payments are all still untouched, exactly as scoped.
diff --git a/docs/phase-6-summary.md b/docs/phase-6-summary.md
index 8320019..97c3c20 100644
--- a/docs/phase-6-summary.md
+++ b/docs/phase-6-summary.md
@@ -158,7 +158,7 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 19. Known limitations
- **Working-hours editing has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward.
-- **Owner-editable breaks/whole-day time off for a staff member were not built** — only partial-day "Block Time" (via `OwnerBlockTimeButton`) satisfies "add time off on behalf of staff" this phase; a staff member's own recurring breaks and whole-day time off can currently only be edited by that staff member themselves.
+- **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
- **"Requires staff approval to redeem" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists.
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
@@ -166,7 +166,7 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 20. Review items for Phase 7
-- Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built, or whether "Block Time" (partial-day) remains the only owner-side lever indefinitely.
+- ~~Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built~~ — built in Phase 7; Block Time edit/delete followed later (see §19).
- Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19.
- Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it.
- Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; `Appointment.discountAmount` exists and is populated correctly, but nothing yet reconciles it against an actual payment.
diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts
index e76acb1..d6330ee 100644
--- a/e2e/customer-journeys.spec.ts
+++ b/e2e/customer-journeys.spec.ts
@@ -66,25 +66,33 @@ test.describe("customer booking journeys", () => {
await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible();
});
- test("3. customer views upcoming appointments", async ({ page }) => {
+ test("3. customer views all appointments in one list", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/appointments");
- // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed.
- await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
- const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name);
+ // One list, no tabs.
+ await expect(page.getByRole("tab")).toHaveCount(0);
+
+ // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed — reschedulable and cancellable.
+ // The seed also has a completed Haircut & Style with Marcus Reed, so pick the card by status too.
+ const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name).filter({ hasText: "Confirmed" });
await expect(upcomingSeeded).toBeVisible();
await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible();
+ await expect(upcomingSeeded.getByRole("link", { name: "Reschedule" })).toBeVisible();
+ await expect(upcomingSeeded.getByRole("button", { name: "Cancel", exact: true })).toBeVisible();
+ await expect(upcomingSeeded.getByRole("button", { name: "Book Again" })).toHaveCount(0);
- // Seeded: a COMPLETED Haircut & Style with Leo Vance.
- await page.getByRole("tab", { name: "Past" }).click();
- const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
+ // Seeded: a COMPLETED Haircut & Style with Leo Vance — same list, Book Again instead.
+ const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
await expect(pastSeeded).toBeVisible();
await expect(pastSeeded.getByText("Completed")).toBeVisible();
+ await expect(pastSeeded.getByRole("button", { name: "Book Again" })).toBeVisible();
+ await expect(pastSeeded.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
- // Alex Chen has no cancelled appointments in the seed data.
- await page.getByRole("tab", { name: "Cancelled" }).click();
- await expect(page.getByText("No cancelled appointments")).toBeVisible();
+ // Newest first: the upcoming appointment is above the completed one.
+ const upcomingBox = await upcomingSeeded.boundingBox();
+ const pastBox = await pastSeeded.boundingBox();
+ expect(upcomingBox!.y).toBeLessThan(pastBox!.y);
});
test("4. customer reschedules an appointment", async ({ page }) => {
@@ -120,11 +128,11 @@ test.describe("customer booking journeys", () => {
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name });
await page.goto("/appointments");
- const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
+ const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
await expect(upcomingCard).toBeVisible();
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
- // Scoped to the currently-open