Built
No-show flag and block:
Two new numbers under Booking Rules (blank means off).
A symbol appears on the clients list, client page, calendar entries and appointment pages.
Online booking is refused at the block number, and customers are told at the details step.
Staff can still book a blocked customer in, and an owner can reset someone with a two-tap button.
The count resets when they attend, and matches the same email or phone, so a guest can’t dodge it with a new profile.
Email rules: staff and owners get email only for customer cancellations, schedule changes and invitations. Routine events (new booking, reschedule) are push-only. Nobody is told about their own action. A barber reassignment sends one customer email instead of two. Reward expiry sends one warning.
Email redesign: your brand colour, a details card, Add to calendar and View on map links, a preferences link, hidden preview text, and dark mode. Replies now go to the business’s own email.
WhatsApp “due now” queue: a list in time order with a highlighted next one, an owner setting for how many hours ahead (12–72), and a once-a-day push telling each barber how many reminders are waiting.
Monitoring: /api/health now reports whether notifications are actually going out. The worker resets stuck deliveries and alerts the owner on the first setup failure (such as a bad SMTP login). The app emails your operator address about server errors, and the manager pushes that address to each business.
Google scope: now only calendar.app.created. Events go to a calendar the app creates in the staff member’s Google account, called “<Business> bookings”.
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.
The owner page
Where: Manage → Business → Holidays, at /owner/settings/holidays. It needs the same permission as Business Details.
Adding: you enter a name, a start date and an optional end date. Leaving the end empty gives a single day.
Upcoming and past: upcoming holidays are listed by date, with a trash icon to remove each one. Past ones sit in a collapsed “Past” list.
Existing bookings: if appointments are already booked on those dates, you see them and confirm with “Add Anyway”, the same pattern as Opening Hours. Nothing is cancelled automatically.
What it does to booking
Customer calendar: holiday dates are greyed out, for every staff member and every service.
Time slots: no slots are offered on those dates.
Direct bookings: staff and owner bookings and reschedules onto a holiday are refused, with a message naming the holiday. This is deliberate, but if you want owners to be able to override it, that’s a small change.
Policies section (business-details-form.tsx)
Late Arrival Policy, Booking Terms and Privacy Policy URL are gone. The section now has Cancellation Policy plus the Terms & Privacy name and email dropdowns.
Their database columns stay, so nothing is lost. Saving no longer touches them.
Email and push only
One switch, IN_APP_NOTIFICATIONS_VISIBLE in in-app.ts, is set to false and controls everything below.
Bell icon: gone for customers, staff and owners, including the unread badge.
Notification pages: /notifications, /staff/notifications and /owner/notifications now return 404.
Settings: Settings → Notifications shows only Email and Push toggles.
Testing: I checked all of this in a local test app as the owner.
Policies section
New settings: two new dropdowns are under Policies in Manage Business:
Name shown on Terms & Privacy: NextChair, My business name, or NextChair and my business name.
Contact email on Terms & Privacy: NextChair (info@next-chair.co.uk) or My business email.
Fallbacks: both default to NextChair. If you pick the business email but haven’t set one, the form warns you and the pages fall back to the NextChair address.
Where the name appears: the choice changes the name in the header link, footer, “we/us” definition, privacy controller line and intro on both pages.
Business email: it replaces the address in the complaints section and the privacy contact lines.
Database change: this needed a new migration, 20261017120000_add_legal_display_settings. Existing businesses default to NextChair, so nothing changes for them. Run prisma migrate deploy on your server.
Terms and placeholders
Sections 15 (Governing law) and 16 (Contact us) are removed from terms/page.tsx, so the Terms now have 14 sections.
The [LEGAL ENTITY NAME] and [REGISTERED ADDRESS] placeholders are gone, along with the unused governing-law setting. The contact email is now info@next-chair.co.uk in legal-config.ts.
Login page
sign-in-form.tsx now shows “By continuing, you agree to our Terms of Service and Privacy Policy”, with both as links.
1. Pre-launch review (no code)
Listed what’s left before going live: testing, backups, monitoring, legal/GDPR, and a dry run on the VPS.
Listed the features a barbershop system usually has and which of them you’re missing.
Researched WhatsApp pricing, which led to the free route below.
2. Security cleanup
This came out of that review.
Deleted: /dev/scheduling-test (a test page that shipped), /api/debug/db-connect-test (a leftover debug route), and two files of unauthenticated booking and appointment actions (11 actions). One of them listed appointments with customer details to anyone.
Audited: all 127 server actions. The only unguarded ones left are the intended public ones: availability lookups, cookie-based booking drafts, and the token-gated one-time installer.
Tidied: removed the four now-dead booking schemas from schemas.ts.
3. Client import from a spreadsheet
Entry points: an upload icon next to “+” on the Clients page, and a link on the Add Client page. It’s owner-only, checked again on the server.
Flow: /staff/clients/import.
Upload a CSV and check the guessed column mapping.
Review the preview, then import in chunks with progress.
A report lists problem rows by spreadsheet row number, with a download.
CSV template: a visible “Download CSV template” button (headers only: First name, Last name, Email, Phone).
Behavior:
Each client gets a sign-in-ready account, the same as adding one by hand.
Nobody is emailed.
Existing emails are left untouched.
Earlier guest bookings under the same email attach automatically.
“Surname, Firstname” names are read the right way round.
People sharing a phone number still import.
Limits: an email is required for every row, CSV only (no .xlsx), up to 5,000 rows per file, and notes aren’t imported.
4. WhatsApp reminders (free, manual route)
Button: a WhatsApp button next to Call and Email on four pages: the owner and barber appointment pages, “Current appointment”, and the client page. It opens WhatsApp with a ready-written reminder, including a “change it” link.
Phone numbers: read as the business’s own country when typed without a country code. This uses the new libphonenumber-js dependency. A number WhatsApp can’t use means no button.
Reminders list: a new /staff/reminders page lists tomorrow’s confirmed bookings in time order.
Each row has a one-tap Send on WhatsApp.
Tapping marks it “Sent 14:46”, with an Undo.
The mark shows on the appointment pages too.
Barbers see their own list; owners see the whole shop with barber names.
Calendar link: “WhatsApp reminders · 3 to send tomorrow” on both calendar pages.
Database: one additive migration, Appointment.whatsappReminderSentAt. Rescheduling clears it, since a reminder for the old time says nothing about the new one.
Audit log: who marked or undid each reminder is recorded.
Tests and verification
Before today Now
Unit tests 297 331
Integration tests 273 285
New unit tests: the CSV parser, import row logic, and WhatsApp link and phone handling.
New integration tests: the import service and the reminders service.
Checks: typecheck and lint are clean (the one old branding warning remains).
Browser: I exercised each feature in a real browser against a throwaway MySQL 8, including the permission split between Leo and Marcus.
Fixed. On a phone, only the calendar scrolls sideways now, and the rest of the page stays put. I checked it on a phone-sized screen and on desktop. The change is uncommitted, in day-calendar.css.
On a 375px screen the page measured 822px wide. After the fix it measures 375px. The cause was my own style. I'd told the calendar grid not to scroll vertically, so the page could own that scrollbar. That also switched off the grid's sideways scrolling, and the grid pushed the whole page wider instead. The grid now scrolls sideways inside itself and doesn't scroll vertically.
I checked a real swipe on the phone-sized screen:
Header and hour labels: the barber names move with their columns, and the hour labels stay pinned on the left.
Top bar: the "Calendar / NEW" bar doesn't move.
Desktop: nothing changed. The page isn't wider than the screen, and the grid has no inner scrollbars.
Tooling: typecheck and lint pass. This was a CSS-only change, so I didn't rerun the unit tests.
The shell around every page sets no content width, so each page chose its own. Business Details was centered, Services and Team were pinned left with a dead right half, and Reports, the dashboard, Current Appointment and Settings stretched edge to edge. I added a shared PageContainer with three widths (narrow, default, wide), always centered, and used it on those pages and on the settings pages.
Two form layouts had their own bugs:
Business Details (your screenshot): six cards in a two-column grid, so a short card next to a tall one left a big gap. It's now two independent columns of similar height.
Loyalty: "Reward Rules" and "Rules & Expiry" each sat alone in the left column with an empty right side. They're now side by side.
On phones, Business Details now stacks its cards in a slightly different order (Branding moves down). Tablets get a slightly wider column than before.
Two things I didn't touch:
Customer pages: Home and Book are a phone-width column floating in the middle of a desktop screen, with the bottom tab bar still showing. That's a bigger design decision, so tell me if you want it.
Narrow pages: about 30 pages already used a centered narrow column, which looks fine.
Guest merge
Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched.
What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted.
One email, several guests: if the email has several guest profiles (one per phone number), all are merged.
No profile yet: the oldest guest profile becomes the account's profile.
Account wins: it keeps its own name and phone, and only gaps get filled.
Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified.
Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries.
Reminders
Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on.
Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away.
In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones.
Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both.
Reward expiry: two emails per reward at most, at 7 days and 1 day before.
Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed.
OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed.
Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
Core logic: types.ts (defaults), notification.service.ts (skipEmail + dedup), booking-notifications.ts, loyalty-notifications.ts, reminder-scheduling.ts, booking.service.ts + scheduling.service.ts (staff-selection capture), features/owner/actions/appointments.ts.
Schema: prisma/schema.prisma + new migration 20260912165903_add_staff_selected_by_customer.
UI: both notification-preferences pages (customer, staff) now use the same default logic as the actual send decision.
Tests: notification.service.integration.test.ts, booking-notifications.integration.test.ts, reminder-scheduling.integration.test.ts, loyalty-notifications.integration.test.ts, plus a new createTestAppointment fixture.
Docs: docs/notifications.md rewritten to match.
Default changes
Exactly as specified — full table is in docs/notifications.md. In short: STAFF_NEW_APPOINTMENT, STAFF_APPOINTMENT_RESCHEDULED, STAFF_APPOINTMENT_REMINDER, OWNER_NEW_BOOKING, OWNER_NO_SHOW now default off; everything else that was already toggleable is untouched.
No longer email (fixed, not a toggle)
BOOKING_COMPLETED, LOYALTY_STAMP_ADDED, LOYALTY_REWARD_REDEEMED — always. BOOKING_BARBER_CHANGED — only when the customer originally booked "any available barber." The customer's 2-hour reminder is removed entirely (24h stays); staff keeps both, just off by default. All via a new skipEmail flag on notify(), checked in one place — no new preference rows for these.
Deduplication
One new EQUIVALENCE_GROUPS map in notification.service.ts (STAFF_NEW_APPOINTMENT>OWNER_NEW_BOOKING, STAFF_APPOINTMENT_CANCELLED>OWNER_BOOKING_CANCELLED). Inside notify(), before creating deliveries, it checks for an already-created equivalent notification for the same (businessId, appointmentId, recipientUserId); if found and at-least-as-relevant, the new one gets zero deliveries on every channel (row still created for audit trail). Only listed types participate, so OWNER_NO_SHOW and any future actionable owner type are never touched.
"Add to Calendar" removed from the staff appointment details page — it never showed for owner/customer views anyway, only here.
Buttons now stack in one column, in exactly the order you asked: Check In → Reschedule → Mark No-Show → Cancel. Reschedule moved from a separate top row into appointment-status-actions.tsx itself (new rescheduleUrl prop) so it can sit in the right spot relative to the status-specific buttons — this also means it correctly disappears for statuses that were never reschedulable (e.g. once In Progress), same as before.
Add Loyalty Stamp is now an icon-only button (add-stamp-button.tsx gained an iconOnly prop) sitting directly opposite "X/Y stamps" inside the card — same click-to-open-modal behavior, just compact.
src/services/notifications/types.ts — every STAFF_*/OWNER_* type is now preference-controlled (customer-facing types are untouched except the existing BOOKING_REMINDER, per your scope call).
src/services/notifications/notification.service.ts — IN_APP now respects the same preference check as EMAIL/PUSH. For every type outside the controlled set (the vast majority) this changes nothing — isChannelEnabled still short-circuits to true.
notification-preferences-form.tsx — generalized from one hardcoded toggle to a real per-type grid (groups prop), each row with Email/Push/In-app columns.
Staff settings → Notifications (page.tsx) — now shows 6 staff-facing toggle groups always, plus 5 owner-facing ones only for whoever actually holds VIEW_REPORTING (the same permission that defines "owner" elsewhere in the app) — so a plain barber never sees owner-only switches that could never apply to them.
Customer settings → Notifications — unchanged behavior, just updated to the new shared component's shape.
1. Summary of changes
The request assumed a push-notification subsystem needed to be built — it already existed in full: the PushSubscription model, subscribe/unsubscribe service and Server Actions, the PushSubscribeToggle opt-in UI, WebPushProvider, and public/sw.js's push/notificationclick handlers were all already implemented and tested. The one real gap: NEXT_PUBLIC_VAPID_PUBLIC_KEY is a Next.js build-time-inlined variable, but every business runs the same prebuilt shared image — so on the actual deployment architecture, every business's browser bundle has always shipped with this key baked in empty, regardless of that business's .env. This was a genuine pre-existing bug, and centralizing VAPID globally is the fix for it, not just a tidiness improvement.
Added a Push Notifications section to the Manager's Settings page, mirroring the SMTP architecture exactly: same JSON storage, same MANAGER_SETTINGS_ENCRYPTION_KEY encryption, same rollout mechanism.
Keys are generate-only (web-push.generateVAPIDKeys()) — no hand-pasted key fields, avoiding a mismatched pair that would silently break every push send.
Regeneration requires explicit confirmation with a clear warning about the impact, shown live and verified in-browser.
Replaced the broken NEXT_PUBLIC_VAPID_PUBLIC_KEY mechanism with a new runtime API route, GET /api/push/vapid-public-key — this is what actually makes centralized VAPID config reach the browser, verified live end-to-end.
Fixed a related bug in web-push-provider.ts: a 401/403 (VAPID signature mismatch — exactly what happens to every subscription after a key rotation) wasn't being treated as an invalid subscription, so rotated-out subscriptions would have failed forever without ever being cleaned up.
Two additional real bugs found and fixed during live verification (not just written correctly on paper): the settings forms weren't refreshing their server-fetched data after a successful save, so the Public/Private Key fields and the "Reapply" button stayed stale-looking immediately after a successful Generate.
No Prisma/schema changes — the existing model already satisfied every field in the request.
2. Architecture used for sharing VAPID configuration
Identical to SMTP: Manager is the one place a human ever generates the key pair; it's stored in stacks/manager/global-settings.json, then pushed into every business's .env (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, PUSH_PROVIDER="web-push") via the same upsertEnvValues + composeUp(dir, {forceRecreate:true}) mechanism, verified live to actually rewrite a scratch business's .env and recreate its container. New businesses get it baked in at provisioning time automatically.
3. How and where the VAPID private key is stored
AES-256-GCM encrypted (same key and mechanism as the SMTP password) inside stacks/manager/global-settings.json. Verified directly: the file contains an {iv, authTag, ciphertext} envelope, never plaintext. Never returned to the Manager UI after generation — only a boolean hasPrivateKey.
4. Prisma/database changes
None. The existing PushSubscription model already covers every field requested.
5. Service worker/PWA changes
None to the service worker itself — public/sw.js's push/notificationclick handlers already worked correctly. The only client change is push-subscribe-toggle.tsx, which now fetches the public key from the new API route instead of a dead build-time constant, and gained a new "unconfigured" state shown up front (before a wasted permission prompt) when push genuinely isn't configured.
6. How tenant push subscriptions are stored
Unchanged, and already correct: each business has its own separate database (confirmed throughout this session's work), so tenant isolation is enforced at the infrastructure level, not just a businessId column — there's no query path from one business's container into another's subscriptions at all.
7. How multiple devices are handled
Unchanged — already correct and already tested (endpoint unique, upsert-on-resubscribe, per-user userId/customerId fan-out in reminder-worker.ts).
8. What happens when a subscription becomes invalid
Unchanged for 404/410. Newly fixed: 401/403 (VAPID mismatch, e.g. after a key rotation) is now also treated as subscriptionInvalid — the stale subscription gets revoked automatically on the next delivery attempt rather than failing silently forever.
9. What happens if VAPID keys are regenerated
Documented in the Manager UI's own confirmation warning (verified live): every existing browser subscription across every business stops receiving pushes, and each device needs to click "Enable Push Notifications" again — but nothing needs to be manually cleaned up, since the app now detects and revokes the broken subscriptions automatically (see #8).
10. Files changed
New: manager/src/lib/push-rollout.ts, manager/src/app/dashboard/settings/push-settings-form.tsx, src/app/api/push/vapid-public-key/route.ts + its test.
Modified: manager/src/lib/global-settings.ts (push section + a fix ensuring email/push sections never clobber each other), manager/src/lib/provisioning.ts, manager/src/lib/compose-template.ts, manager/src/app/dashboard/settings/{actions.ts,page.tsx,email-settings-form.tsx} (the last one gained the same router.refresh() fix), push-subscribe-toggle.tsx, web-push-provider.ts + its test, .env.example, .env.business.example, manager/package.json/package-lock.json (added web-push).
11. New environment variables
None new. MANAGER_SETTINGS_ENCRYPTION_KEY already exists from the SMTP work and is reused. NEXT_PUBLIC_VAPID_PUBLIC_KEY is removed (dead — superseded by the API route).
12. VPS deployment/update steps
Rebuild/redeploy both images as usual (no build-arg needed, unlike a naive fix would have required). Open Manager → Settings → Push Notifications, set a contact email, click "Generate VAPID Keys" — this automatically rolls out to every existing business. Nothing else needs to run by hand.
13. Manual testing steps
EMAIL_PROVIDER=console PUSH_PROVIDER=console npm run dev
Generate keys in Manager, confirm the overwrite-warning on a second attempt, confirm a business's .env gains the 4 keys. Hit curl localhost:PORT/api/push/vapid-public-key — confirmed live to return exactly the configured public key. Sign in to a business and visit Settings → Notifications — confirmed live the toggle correctly reaches the real permission-check step (proving the key delivery works) rather than showing "not configured."
The global VAPID / Web Push feature is already fully designed, implemented, and verified — all three research agents finished, I synthesized their findings, wrote the plan, got it approved, built it, found and fixed three real bugs along the way (the broken build-time public-key delivery, the missing 401/403 invalidation handling, and the stale-form-data-after-save issue in both settings forms), and delivered the full report above. That work is done.
1. Architecture chosen
Manager pushes environment variables into every business's .env, rather than businesses fetching config at runtime. Manager has no database of its own today (confirmed via code — no Prisma, no ORM dependency anywhere in manager/); every piece of its state is either an env var or a flat file under STACKS_DIR. A "runtime shared-config source" design would make every single email send (including OTP login) depend on Manager being reachable — a new failure mode this app has never had, for no real benefit. Instead:
Storage: one JSON file, stacks/manager/global-settings.json (gitignored), shaped for future sections (SMS, storage, backups, branding...) to slot in as sibling keys.
Secret at rest: the SMTP password is AES-256-GCM encrypted with a new MANAGER_SETTINGS_ENCRYPTION_KEY, never returned to the browser.
Rollout: saving complete settings automatically pushes the 7 EMAIL_* keys into every business's .env via the exact same surgical upsertEnvValues helper updateBusiness() already uses for name/domain edits, then force-recreates each container (composeUp(dir, {forceRecreate:true})) — the identical mechanism that already applies domain changes today.
New businesses get the current global settings baked into their .env at provisioning time — no manual step, ever.
2. Files changed
New (Manager): global-settings.ts (storage+encryption), email-rollout.ts (push-to-all-businesses), test-email.ts (standalone nodemailer test-send), dashboard/settings/ (page.tsx, actions.ts, email-settings-form.tsx).
Modified (Manager): compose-template.ts (optional email block in generated .env), provisioning.ts (reads global settings at creation), dashboard/layout.tsx (Settings nav link), package.json/package-lock.json (added nodemailer).
Modified (main app): brevo-email-provider.ts (added encryption mode + replyTo, backward-compatible), its test file (8 new tests).
Modified (docs): root .gitignore, .env.example, .env.business.example, manager/.env.example.
3. New volumes/databases/config files
None — no new Docker volume, no database. One new gitignored file, stacks/manager/global-settings.json, living inside the STACKS_DIR bind-mount Manager already has.
4. New environment variables
MANAGER_SETTINGS_ENCRYPTION_KEY (Manager only, required to use Settings) — 32-byte hex, generate with openssl rand -hex 32.
EMAIL_SMTP_ENCRYPTION and EMAIL_REPLY_TO (main app, both optional) — unset EMAIL_SMTP_ENCRYPTION preserves today's exact port-based inference, so existing deployments are unaffected.
Reply-to is now business-specific with global fallback,
5. How settings are persisted
stacks/manager/global-settings.json, written by saveEmailSettings(). The password is encrypted before writing; every other field is plain text (host, port, username, from-address, from-name, reply-to, encryption mode, last-test result) since none of those are secrets.
6. How business containers receive global settings
Via their own .env file (Docker Compose's env_file:, unchanged mechanism) — Manager writes the 7 EMAIL_* keys into it and recreates the container. Nothing in the business app itself needed a new runtime dependency; getEmailProvider() still just reads process.env once at process start, exactly as before.
7. What happens to existing businesses when settings change
Saving complete settings (host+username+password+from-address all present) automatically loops every business, updates its .env, and force-recreates its container in one action — the UI reports "applied to N of M businesses" with per-business failure reasons. A separate "Reapply to all businesses" button retries this without re-entering anything, for recovering from a business that was down during the last save. Saving incomplete settings persists them as a draft and touches no business (verified live — this is what stops a business from being flipped into a broken "brevo with blank credentials" state).
8. Manual deployment/update steps on the VPS
Generate a key: openssl rand -hex 32, add it as MANAGER_SETTINGS_ENCRYPTION_KEY to stacks/manager/.env.
Rebuild/redeploy the Manager image and the main app image (both changed) as usual.
Open Manager → Settings, fill in real SMTP details, send a test email, then Save — this rolls out to every existing business automatically. Nothing else needs to run by hand.
9. Security considerations
Password never logged, never returned to the browser after save (only a hasPassword boolean) — confirmed live: the form re-shows a masked placeholder, not the real value.
Test-email errors are categorized (auth/connection/timeout) before display — the raw SMTP error is never forwarded.
Encrypted at rest with AES-256-GCM; losing/rotating the key makes the stored password undecryptable (documented in manager/.env.example).
Settings routes sit under manager/src/proxy.ts's existing auth gate — no new auth surface.
Validated before rollout: malformed addresses/ports are rejected before Save, and rollout only fires when configuration is actually complete.
On JSON vs. database for Manager: JSON is the right call here. Reasoning:
Manager has zero DB dependencies today, deliberately — its own package.json description says an earlier attempt at giving it business-data access "repeatedly broke." Introducing SQLite/Prisma just for one settings blob breaks that design principle for not much gain.
The access pattern is a poor fit for a DB anyway: read on page-load/provisioning/rollout, written by a human clicking Save occasionally. No queries, no joins, no need for transactions beyond "read-modify-write one small file."
Reconsider if: multiple admins need to edit concurrently with conflict detection, you want an audit history of who-changed-what-when beyond "last test result," or the settings graph grows relational (e.g., per-business overrides tied by foreign key to businesses) rather than a flat global blob. None of that applies yet.
1. Summary of changes
The installer-created account is no longer special. "Owner" is now literally renamed Administrator and behaves exactly like the existing role/permission system already allowed — the codebase turned out to already define "administrator" structurally (a StaffProfile whose role holds MANAGE_STAFF), not as a name or a business.ownerId-style field, and last-administrator protection already existed. The real work was: closing the gap between that and a full admin-management UI, fixing two OTP-migration bugs the new spec's requirements exposed, and making the existing protection transactional.
Businesses can now have any number of Administrators, added via Invite Administrator (name + email → OTP-verified before access), promoted/demoted via the existing staff role dropdown (now intercepted with a confirmation dialog when it crosses the Administrator boundary), or removed via the existing deactivate flow.
Two real bugs found and fixed, both required for the spec's "unverified email must not get access":
Invitation acceptance (/invite/accept) never actually verified an OTP — it silently relied on a sign-in path that would have failed for every real invitee. It now sends and verifies a real code, reusing the shared OtpCodeStep UI.
A newly-invited (not-yet-accepted) account could sign in via plain email OTP before ever touching the invite link, because sign-in only checked "does a User row exist," not whether it was verified. Fixed in the shared OTP service, benefiting every invitation, not just administrators'.
Last-administrator protection (already existed) is now transactional — a SELECT ... FOR UPDATE lock on the business row closes the race where two concurrent removals of two different administrators could both succeed.
Administrators (any staff member, in fact) can now change their own email — new address must be OTP-verified before it replaces the login email.
No hidden installer privilege: confirmed via code audit and a live install → the account is a plain Administrator-role staff member, provable identically via the Team UI as any later-added one.
NextChair Manager and platform Super Administrator are untouched.
2. Prisma/database changes
No schema changes. One data-only migration: prisma/migrations/20260905214944_administrator_role_rename — UPDATE Role SET name = 'Administrator' WHERE name = 'Owner' AND isSystem = true. Runs through the normal migration-deploy process; not something to run by hand.
3. How Administrator roles are represented
A per-business Role row named "Administrator" (was "Owner"), isSystem: true, holding every Permission — created identically at install and in dev seed data. This is what "Invite"/"Make Administrator" assign.
Whether a StaffProfile currently counts as an Administrator is permission-based (holds MANAGE_STAFF), not a name check — the same definition the codebase already used for last-owner protection (isStaffAdministrator in staff.service.ts). This is deliberately reused rather than introduced, and stays correct if a business ever gets a custom role that also grants MANAGE_STAFF.
No Business.adminId-style field anywhere — confirmed none existed before this work either.
4. How administrator invitations work
Reuses the existing StaffInvitation model and createStaffProfile/createInvitation machinery verbatim — a new inviteAdministratorAction just pre-selects the Administrator role and uses a minimal Name+Email form (invite-administrator-form.tsx). Acceptance (accept-invitation.ts) now requires a real OTP code sent to the invited email before the account activates — expiry (7 days), resend, and revoke are all the pre-existing invitation mechanics (revoke = the existing deactivate action, relabeled "Revoke Invitation" while pending).
5. How last-administrator protection is enforced
Server-side only, inside staff.service.ts's updateStaffAsOwner (role change) and deactivateStaff (remove), each wrapped in prisma.$transaction that first locks the business row (SELECT ... FOR UPDATE), then counts other active Administrators before allowing the change. Verified with a genuine concurrency test (two simultaneous removals against a 2-admin business) and manually in-browser (see below).
6. Installation flow changes
Only the role name and one line of wizard copy changed ("This becomes the first Administrator..."). No structural UX change — still collects name/email, verifies via OTP, creates/activates the account, assigns the role, exactly as before.
7. Files changed
New: the migration; email-change.service.ts + change-email.ts action + change-email-section.tsx; invite-administrator-form.tsx + add-administrator/page.tsx; 3 new integration test files.
Modified — core: staff.service.ts (transactional protection, isStaffAdministrator), staff-invitation.service.ts, otp.service.ts (onlyIfUserExists fix, email-change purpose), default-permissions.ts, permissions.ts, auth.ts (JWT email-refresh, simplified finalize path).
Modified — actions/UI: owner/actions/staff.ts, staff-detail-panel.tsx, team/page.tsx, add-staff-form.tsx, accept-invitation.ts/form/page, otp-code-step.tsx, profile-form.tsx, installer files, seed.ts, plus a terminology sweep ("owner" → "administrator" copy in breaks.ts/time-off.ts/staff-emails.ts/profile page).
8. Manual testing steps
Sign in as the seeded owner → /owner/team shows an Administrator badge and an "Invite Administrator" icon next to "+". Invite someone, accept via /invite/accept?token=... printed to the terminal (code also prints there) — they land signed in with full Manage access. Promote/demote any staff member via the role dropdown on their detail page — crossing the Administrator boundary shows a confirmation dialog. With only one Administrator left, attempting to demote or deactivate them shows "You can't remove the only administrator. Add another administrator first." Change your own email from Profile settings — the new address must be OTP-verified before it replaces the old one.
9. Deployment steps on the VPS
None beyond the normal deploy process — the migration is a standard Prisma migration that prisma migrate deploy (whatever already runs it there) will pick up automatically. No new environment variables. No changes to NextChair Manager or its authentication.
Verification performed: tsc/lint/unit — all clean; full test:integration — 219 passed (up from 207, all new); full test:e2e — same 36/37 as before this work (the 1 failure is a pre-existing, auth-unrelated flake unrelated to any of this); a full live manual pass covering every scenario above, including the last-administrator block and the email-change JWT-refresh behavior.
1. Summary of changes
Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point.
NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction.
Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL.
Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally.
2. Prisma/database changes
New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored.
User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched.
Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session).
3. New environment variables
None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else.
4. Email delivery configuration
Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop).
5. Migration considerations for existing users/installations
Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change.
No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore.
prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently).
Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step.
6. Files changed
New:
src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service
src/lib/auth/__tests__/otp.service.integration.test.ts
src/services/notifications/templates/otp-email.ts
src/features/auth/components/otp-code-step.tsx — shared code-entry UI
src/features/auth/actions/request-sign-in-otp.ts
src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE
prisma/migrations/20260905190022_email_otp_authentication/
Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts
Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts
Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts
Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call).
7. Verification performed
npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean.
npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here.
Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced.
Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields.
All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
The redirect loop was caused by the Manager cookie configuration:
1. secure was based on NODE_ENV=production, so HTTP deployments silently dropped the cookie.
2. Path=/manager excluded /api/manager/*, preventing authenticated Manager API requests.
Implemented
- Root cause fixed in [src/proxy.ts]: Auth.js is now initialized lazily and never invoked when MANAGER_MODE=true.
- Manager requests call handleProxyRequest directly, preserving redirects and CSP.
- Business requests continue through Auth.js with existing protected-route behavior.
- Added coverage in [proxy-manager-mode.test.ts]