Compare commits

..
47 Commits
Author SHA1 Message Date
Stefan Lazic 70cb874ff5 Update proxy.ts
CI / ci (push) Canceled after 0s
2026-10-18 11:40:19 +01:00
Stefan Lazic 21299f0287 gallery changes
CI / ci (push) Canceled after 0s
Hero gallery: the slideshow on Home doesn’t open full screen. Only portfolio images do, as you asked.
Lightbox: it has no pinch-zoom.
Delete: removing an image still has no confirm step, same as before.
New dependency: drag-and-drop uses @dnd-kit, which I added to package.json.
Touch dragging: I couldn’t test it on a real phone. Mouse and keyboard dragging work.
Health test: src/app/api/health/__tests__/route.test.ts passes, but it prints unhandled rejections. Its prisma mock has no notificationDelivery, so getQueueHealth throws. That comes from the earlier “Remaining features” commit and I left it alone. It’s a quick fix if you want it.
2026-10-18 11:23:17 +01:00
Stefan Lazic 96d5533638 Update page.tsx
CI / ci (push) Canceled after 0s
2026-10-18 10:29:36 +01:00
Stefan Lazic 95ff9bd70c Package Updates
CI / ci (push) Canceled after 0s
Changed files: only package.json and package-lock.json in the app and the manager.
Main versions:
Next.js: 16.2.12 (app) and 16.3.4 (manager) → 16.4.2 in both.
React and React DOM: 19.3.1.
Prisma: 7.10.1, including the client and the MariaDB adapter.
Smaller bumps: mariadb 3.5.4, image-size 2.0.4, postcss, zod, lucide-react, react-hook-form, the Radix packages and Playwright.
Verified: typecheck and lint are clean. All 420 app unit tests, 198 manager tests and 345 integration tests on MySQL 8 pass. The Prisma migrations show no drift. Both production builds succeed, and I clicked through the calendar, business settings, reminders and booking screens with no errors from the upgrade.
2026-10-18 10:05:59 +01:00
Stefan Lazic 68a62e02a4 Remaining features
CI / ci (push) Canceled after 0s
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”.
2026-10-17 23:10:18 +01:00
Stefan Lazic 17eee5b932 Updates 1
CI / ci (push) Canceled after 0s
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.
2026-10-17 22:29:00 +01:00
Stefan Lazic 9f981173f4 holidays feature
CI / ci (push) Canceled after 0s
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.
2026-10-17 12:23:53 +01:00
Stefan Lazic 31c48296d9 In App Notifications
CI / ci (push) Canceled after 0s
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.
2026-10-17 11:34:30 +01:00
Stefan Lazic 766a0808ab Terms 2
CI / ci (push) Canceled after 0s
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.
2026-10-17 10:38:07 +01:00
Stefan Lazic 05ea83e059 Fixes
CI / ci (push) Canceled after 0s
fixed super admin showing in client list

added a reconnect button to google calendar syncying

fixed ui issue with whatsapp undo link
2026-10-17 09:35:52 +01:00
Stefan Lazic 2ed9c5ecca security cleanup, client import, WhatsApp reminders
CI / ci (push) Canceled after 0s
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.
2026-10-15 21:25:09 +01:00
Stefan Lazic 04d6192d51 Update day-calendar.css
CI / ci (push) Canceled after 0s
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.
2026-10-08 11:20:12 +01:00
Stefan Lazic a8a8d1e045 Updated Calendard
CI / ci (push) Canceled after 0s
The calendar is now react-big-calendar.
2026-10-08 11:01:32 +01:00
Stefan Lazic 8c267eff25 desktop layout
CI / ci (push) Canceled after 0s
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.
2026-10-08 10:37:44 +01:00
Stefan Lazic a530d00aa2 Typecheck
CI / ci (push) Canceled after 0s
2026-10-07 16:54:08 +01:00
Stefan Lazic 1442d33d55 Fixes
CI / ci (push) Canceled after 0s
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).
2026-10-07 16:39:12 +01:00
Stefan Lazic 7338c9d9e2 Update page.tsx
CI / ci (push) Canceled after 0s
2026-09-18 21:07:37 +01:00
Stefan Lazic 410aa07596 add client 2
CI / ci (push) Canceled after 0s
now auto shows after being added.
2026-09-14 13:17:07 +01:00
Stefan Lazic 0d821ce1f8 add client
CI / ci (push) Canceled after 0s
adds a button to add a new client from the barber menu
2026-09-14 12:25:45 +01:00
Stefan Lazic 91f3fd77d7 Update install-wizard.tsx
CI / ci (push) Canceled after 0s
2026-09-13 19:59:42 +01:00
Stefan Lazic 4573587b78 Email Notifictions
CI / ci (push) Canceled after 0s
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.
2026-09-12 18:27:11 +01:00
Stefan Lazic 7224c87cef Header Update
CI / ci (push) Canceled after 0s
Updated the header of these pages to match the rest of the pages.
2026-09-12 17:24:47 +01:00
Stefan Lazic 1932cf7774 Update page.tsx
CI / ci (push) Canceled after 0s
2026-09-12 15:49:28 +01:00
Stefan Lazic de32175916 Google Calendar
CI / ci (push) Canceled after 0s
Made google calendar auth not business specific.

global-settings.ts — new GoogleCalendarSettings section, encrypted at rest the same way as the SMTP password/VAPID private key.

google-calendar-rollout.ts — pushes the shared client ID/secret into every business's .env and recreates its container. GOOGLE_CALENDAR_REDIRECT_URI is left alone (it must keep tracking each business's own domain).

provisioning.ts / compose-template.ts — new businesses now pick up the global client automatically at creation, same as Email/Push already do.

Settings UI — new "Google Calendar" card at the bottom of Manager's Settings page (google-calendar-settings-form.tsx), with Save + "Reapply to all businesses."
2026-09-12 15:09:48 +01:00
Stefan Lazic 65e141d527 Update page.tsx
CI / ci (push) Canceled after 0s
2026-09-12 11:35:09 +01:00
Stefan Lazic 0e39ca7cdc Owner appointment details
CI / ci (push) Canceled after 0s
same changes applied to this route
2026-09-12 10:52:48 +01:00
Stefan Lazic b1fce9c002 Appointment details layout 2
CI / ci (push) Canceled after 0s
"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.
2026-09-12 10:21:29 +01:00
Stefan Lazic f7fad3d1a0 Appointment details layout
CI / ci (push) Canceled after 0s
fixed button layout on appointment details page.

added an explainer for all the notifications
2026-09-12 10:01:55 +01:00
Stefan Lazic f24d22fdf1 Notification Settings
CI / ci (push) Canceled after 0s
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.
2026-09-12 09:41:37 +01:00
Stefan Lazic bfa13c5f15 Update README.md
CI / ci (push) Canceled after 0s
Fix notification-delivery cron docs: cron may not be installed, /var/log/ needs root
2026-09-11 18:16:17 +01:00
Stefan Lazic 1a76d1cacb Notifications
CI / ci (push) Canceled after 0s
Added a cron job processor so emails and notifications can get processed.

Added super admin settings with a gui to edit.
2026-09-11 17:46:28 +01:00
Stefan Lazic cbeccc6c1f Auth Fix
CI / ci (push) Canceled after 0s
Fixed an issue where the user would be redirected to a localhost address because an auth url was not set in the env.
2026-09-06 21:01:54 +01:00
Stefan Lazic e9e28a243f Installation
CI / ci (push) Canceled after 0s
install token now shown in dashboard until install completes.
2026-09-06 20:14:25 +01:00
Stefan Lazic d7a8b15482 Demo business removal
CI / ci (push) Canceled after 0s
Removed the demo business as its easier to just provision one manually.
2026-09-06 19:01:41 +01:00
Stefan Lazic 217c0b0101 Demo Business
CI / ci (push) Canceled after 0s
Updated references to a demo business.
2026-09-06 18:53:02 +01:00
Stefan Lazic 4763b50e62 Dockge Removal
CI / ci (push) Canceled after 0s
Removed dockge from the app as it is not required due to the manager page picking up most of its abilities.
2026-09-06 18:33:01 +01:00
Stefan Lazic 973cb9efc6 Update README.md
CI / ci (push) Canceled after 0s
2026-09-06 15:54:44 +01:00
Stefan Lazic 479cefd085 Update README.md
CI / ci (push) Canceled after 0s
2026-09-06 13:02:20 +01:00
Stefan Lazic 106f89a6da VAPID Settings
CI / ci (push) Canceled after 0s
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.
2026-09-06 12:17:10 +01:00
Stefan Lazic 2d4f9c5bf0 Email Settings
CI / ci (push) Canceled after 0s
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.
2026-09-06 10:38:21 +01:00
Stefan Lazic 973187dcc7 Multi-administrator support
CI / ci (push) Canceled after 0s
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.
2026-09-05 23:32:54 +01:00
Stefan Lazic 94aa1b2f26 OPT and Improved Installation Wizzard
CI / ci (push) Canceled after 0s
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.
2026-09-05 21:03:36 +01:00
Stefan Lazic ba46334179 Delete Business
CI / ci (push) Canceled after 0s
A Delete button now sits next to Edit on each business's detail page, styled in red to distinguish it from the safe actions.
It opens a dedicated confirmation page (matching the Add/Edit page pattern) that clearly lists exactly what gets destroyed — container + media volume, the database by name, the Caddy site/domain, and the stack directory — and requires typing the business's exact slug before the Delete button even becomes clickable. This is a stronger guard than a plain "are you sure?" popup, given what we just learned about how easy it is to lose data here.
deleteBusiness() in provisioning.ts reuses the same primitives as create/edit (composeDown, reloadCaddy, the shared MySQL root connection), and is deliberately best-effort past the first step — if a piece is already gone (matching exactly the kind of half-broken state we just fixed for blake-heritage), it still finishes removing the rest and reports what it couldn't clean up automatically, rather than getting stuck.
Verified live: provisioned a real test business, confirmed its container/database/user/volume/Caddy site all existed, deleted it, and confirmed every single one was actually gone — including checking Caddy's running config (not just the file on disk) to prove the reload took effect. Also confirmed the confirm-button correctly stays disabled for a wrong slug and only enables on an exact match. Your real production database was never touched throughout.
2026-09-05 17:41:38 +01:00
Stefan Lazic 2e739c9a26 Update provisioning.ts
CI / ci (push) Canceled after 0s
2026-09-05 17:14:39 +01:00
Stefan Lazic 3b07124d7e Edit Business flow
CI / ci (push) Canceled after 0s
New files:

manager/src/lib/env-file.ts — a shared, surgical .env reader/updater. It only ever touches the specific keys you tell it to (never regenerates the file), so anything hand-edited into a business's .env since provisioning is preserved exactly.
manager/src/app/dashboard/[slug]/edit/ — the edit page, its form component, and its server action.

Modified:

provisioning.ts gained updateBusiness() alongside the existing provisionBusiness(), reusing the same Caddy-site-writing and container-recreation logic (refactored into a shared writeCaddySiteAndReload helper).
stacks.ts/compose-template.ts — extended to track a BUSINESS_NAME field (previously collected on the Add Business form but silently discarded — that gap is now fixed too, so newly created businesses get their name persisted from the start).
The detail page and dashboard list now show the business name, and the detail page has an Edit button.

Per the requirements:

Slug is read-only in the edit form (disabled input, never accepted by the server action — it's bound in from the route, not form data).
Domain uniqueness is validated against every other business (and correctly self-excludes the business being edited).
A domain change rewrites the Caddy site file, reloads Caddy, and force-recreates the container; a name-only change does neither.
No secrets (DATABASE_PASSWORD, AUTH_SECRET, etc.) are ever read into the edit UI — only name/domain/databaseName/status are exposed.
Clear success banners ("Changes saved" vs. "...the container was restarted...") and inline error display, matching the existing Add Business pattern.
2026-09-05 17:05:33 +01:00
Stefan Lazic c92ee46d76 Delete compose.yaml
CI / ci (push) Canceled after 0s
2026-09-05 14:03:15 +01:00
Stefan Lazic a0d39026c0 Management UI
CI / ci (push) Canceled after 0s
Attempt 2 of a management ui.
2026-09-05 13:39:55 +01:00
403 changed files with 38839 additions and 4636 deletions

No files matched your search

+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3000
},
{
"name": "nextchair-manager-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "manager", "run", "dev", "--", "-p", "3100"],
"port": 3100
} }
] ]
} }
+7
View File
@@ -3,6 +3,13 @@ node_modules
generated generated
.git .git
.gitea .gitea
# A fully separate app (own package.json, own Dockerfile) — has no
# business being copied into the main app's build context at all, and
# its own @/* import alias resolving against the main app's tsconfig
# (via the include glob's **/*.ts) broke a plain `docker build` here
# before this line existed.
manager
stacks
.env .env
.env.* .env.*
!.env.example !.env.example
+56 -27
View File
@@ -1,10 +1,11 @@
# One business's config on the shared multi-business VPS setup — see # One business's config on the shared multi-business VPS setup — see
# docker-compose.infra.yaml for the one-time setup this depends on # stacks/infra/compose.yaml for the one-time setup this depends on
# (creating this business's database on the shared MySQL server) and # (creating this business's database on the shared MySQL server) and
# docker-compose.<business>.yaml for how this file is used. Copy this to # stacks/<slug>/compose.yaml for how this file is used. Copy this to
# `.env.business.<slug>` (e.g. `.env.business.blake-heritage` — kept out # `stacks/<slug>/.env` (kept out of git, see .gitignore) per business and
# of git, see .gitignore) per business and fill in real values. Nothing # fill in real values. Nothing here is a real credential. Normally the
# here is a real credential. # management UI (manager/) generates this file for you — this template is
# for the by-hand setup path.
# #
# The app starts and runs a complete first-install flow with ONLY the # The app starts and runs a complete first-install flow with ONLY the
# "Required for basic startup" section below filled in — every other # "Required for basic startup" section below filled in — every other
@@ -12,47 +13,63 @@
# disabled state rather than crashing when its variables are left blank. # disabled state rather than crashing when its variables are left blank.
# ── Required for basic startup ───────────────────────────────────────── # ── Required for basic startup ─────────────────────────────────────────
# The public domain this business is served on — used by Traefik's # Manager-only bookkeeping (shown on the dashboard/edit screen, editable
# routing label (see docker-compose.<business>.yaml) to decide which # there) — the app itself never reads this; its own business name lives
# container handles requests for it, so this must be set and must match # in the database, set via /install.
# the domain's real DNS (pointed at the VPS) before Traefik can issue a BUSINESS_NAME="Example Business"
# The public domain this business is served on — must match the .caddy
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
# domain's real DNS (pointed at the VPS) before Caddy can issue a
# certificate for it. # certificate for it.
DOMAIN="blake-heritage.example.com" DOMAIN="example-business.example.com"
# The app reaches MySQL via the shared infra's service hostname # The app reaches MySQL via the shared infra's service hostname
# `next-chair-mysql`, never `localhost` — see docker-compose.infra.yaml. # `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for # DATABASE_NAME/USER/PASSWORD must match exactly what you created for
# this business on the shared server (see docker-compose.infra.yaml's # this business on the shared server (see stacks/infra/compose.yaml's
# "CREATE DATABASE ..." step) — this file doesn't create them, it only # "CREATE DATABASE ..." step) — this file doesn't create them, it only
# tells the app what to connect as. # tells the app what to connect as.
DATABASE_HOST="next-chair-mysql" DATABASE_HOST="next-chair-mysql"
DATABASE_PORT="3306" DATABASE_PORT="3306"
DATABASE_NAME="blake_heritage" DATABASE_NAME="example_business"
DATABASE_USER="blake_heritage" DATABASE_USER="example_business"
DATABASE_PASSWORD="replace-me" DATABASE_PASSWORD="replace-me"
# Generate with: npx auth secret # Generate with: npx auth secret
AUTH_SECRET="replace-me-with-a-32-byte-random-string" AUTH_SECRET="replace-me-with-a-32-byte-random-string"
# Auth.js needs to trust the host it's served from when not on localhost:3000. # Auth.js needs to trust the host it's served from when not on localhost:3000.
AUTH_TRUST_HOST="true" AUTH_TRUST_HOST="true"
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com" # Explicit, not just inferred from AUTH_TRUST_HOST + request headers —
# header-based detection (Host/X-Forwarded-Host) behaves inconsistently
# across reverse-proxy setups, and a wrong guess sends every post-sign-in
# redirect to the wrong origin. Must match DOMAIN above exactly.
AUTH_URL="https://example-business.example.com"
NEXT_PUBLIC_APP_URL="https://example-business.example.com"
# One-time setup token the installation wizard (/install) requires before # One-time setup token the installation wizard (/install) requires before
# it will create the first business/administrator — generate with: # it will create the first business/administrator — generate with:
# openssl rand -hex 32 # openssl rand -hex 32
INSTALLATION_TOKEN="replace-me" INSTALLATION_TOKEN="replace-me"
# Shared secret an external scheduler presents to # Shared secret an external scheduler presents to
# /api/cron/process-notifications — generate with: openssl rand -hex 32 # /api/cron/process-notifications and /api/cron/sync-google-reviews —
# generate with: openssl rand -hex 32. Without something actually calling
# those on a schedule, queued notifications (staff invites, reminders,
# etc.) never get sent — see the root README's "Notification delivery"
# section: the Manager's built-in scheduler normally does this (the host
# crontab script is only a fallback).
CRON_SECRET="replace-me" CRON_SECRET="replace-me"
# Where the app emails unhandled server errors (one email per distinct error per hour, max 20 a day).
# The Manager fills this in from Settings > Monitoring. Blank = no error emails.
OPERATOR_ALERT_EMAIL=""
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ── # ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
# Platform-level account with every Owner permission. Not run # Platform-level account with every Owner permission for THIS business.
# automatically by this compose file (that's a Docker-entrypoint-only # Runs automatically on every container start (see docker/entrypoint.sh)
# behaviour, see docker/entrypoint.sh) — run it manually, once, the same # — filling these in and recreating the container is enough, no manual
# way migrations were run for the Vercel/Hostinger deployment earlier: # `docker exec` needed. No password — signs in the same OTP way as
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts # everyone else, via a one-time code emailed to SUPER_ADMIN_EMAIL. Leave
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install # SUPER_ADMIN_EMAIL blank to skip entirely and use the /install wizard's
# wizard's own administrator account instead. # own administrator account instead.
SUPER_ADMIN_EMAIL="" SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME="" SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME="" SUPER_ADMIN_LAST_NAME=""
@@ -65,7 +82,7 @@ AUTH_GOOGLE_SECRET=""
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes). # OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
GOOGLE_CALENDAR_CLIENT_ID="" GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET="" GOOGLE_CALENDAR_CLIENT_SECRET=""
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback" GOOGLE_CALENDAR_REDIRECT_URI="https://example-business.example.com/api/staff/calendar/google/callback"
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32 # Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
CALENDAR_TOKEN_ENCRYPTION_KEY="" CALENDAR_TOKEN_ENCRYPTION_KEY=""
@@ -83,18 +100,30 @@ MEDIA_MAX_UPLOAD_BYTES="5000000"
GOOGLE_PLACES_API_KEY="" GOOGLE_PLACES_API_KEY=""
# ── Email (optional — defaults to logging to the console instead of sending) ─ # ── Email (optional — defaults to logging to the console instead of sending) ─
# Managed centrally by the Manager's Settings > Email page and pushed
# into every business's .env automatically — see manager/src/lib/
# global-settings.ts. There's normally no reason to hand-edit these for
# one business only; this by-hand template still needs them set if
# you're not using the Manager.
EMAIL_PROVIDER="console" EMAIL_PROVIDER="console"
EMAIL_SMTP_HOST="smtp-relay.brevo.com" EMAIL_SMTP_HOST="smtp-relay.brevo.com"
EMAIL_SMTP_PORT="587" EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USERNAME="" EMAIL_SMTP_USERNAME=""
EMAIL_SMTP_PASSWORD="" EMAIL_SMTP_PASSWORD=""
EMAIL_SMTP_ENCRYPTION="starttls"
EMAIL_FROM_ADDRESS="" EMAIL_FROM_ADDRESS=""
EMAIL_FROM_NAME="" EMAIL_FROM_NAME=""
EMAIL_REPLY_TO=""
# ── Web Push (optional — defaults to logging to the console instead of sending) ─ # ── Web Push (optional — defaults to logging to the console instead of sending) ─
# Generate with: npx web-push generate-vapid-keys # Managed centrally by the Manager's Settings > Push Notifications page and
# pushed into every business's .env automatically — see manager/src/lib/
# global-settings.ts. There's normally no reason to hand-edit these for
# one business only; this by-hand template still needs them set if you're
# not using the Manager. No NEXT_PUBLIC_VAPID_PUBLIC_KEY here — the app
# serves its public key at runtime via GET /api/push/vapid-public-key
# instead (see that route's own header for why).
PUSH_PROVIDER="console" PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY="" VAPID_PUBLIC_KEY=""
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY="" VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com" VAPID_SUBJECT="mailto:owner@example.com"
+24 -11
View File
@@ -32,27 +32,40 @@ GOOGLE_PLACES_API_KEY=""
# Email (Phase 7) — see src/services/notifications/providers. # Email (Phase 7) — see src/services/notifications/providers.
# "console" logs would-be emails to the server console instead of sending # "console" logs would-be emails to the server console instead of sending
# (safe default — never send real email from an unconfigured environment). # (safe default — never send real email from an unconfigured environment).
# "brevo" sends for real over Brevo's SMTP relay via nodemailer (see # "brevo" sends for real over SMTP via nodemailer (see brevo-email-
# brevo-email-provider.ts for why SMTP rather than Brevo's REST API). # provider.ts — it's genuinely generic SMTP, not Brevo-API-specific,
# despite the name). On a multi-business deployment these are normally
# managed centrally by the Manager's Settings > Email page and pushed into
# every business's .env automatically — you don't need to fill these in
# by hand there. This single-business template still needs them set.
EMAIL_PROVIDER="console" EMAIL_PROVIDER="console"
EMAIL_SMTP_HOST="smtp-relay.brevo.com" EMAIL_SMTP_HOST="smtp-relay.brevo.com"
EMAIL_SMTP_PORT="587" EMAIL_SMTP_PORT="587"
EMAIL_SMTP_USERNAME="" EMAIL_SMTP_USERNAME=""
EMAIL_SMTP_PASSWORD="" EMAIL_SMTP_PASSWORD=""
# "none" | "starttls" | "tls" — unset infers from port (465 = tls, else
# starttls) for backward compatibility; set explicitly to represent "none".
EMAIL_SMTP_ENCRYPTION="starttls"
EMAIL_FROM_ADDRESS="" EMAIL_FROM_ADDRESS=""
# EMAIL_FROM_NAME is optional and is only used when no business name can # EMAIL_FROM_NAME is optional and is only used when no business name can
# be resolved. Normal business emails use the installed business name # be resolved. Normal business emails use the installed business name
# automatically (see docs/phase-8-email-sender-summary.md) — you do not # automatically (see docs/phase-8-email-sender-summary.md) — you do not
# need to set or keep this in sync with Business Settings. # need to set or keep this in sync with Business Settings.
EMAIL_FROM_NAME="" EMAIL_FROM_NAME=""
# Optional — a distinct reply-to address, separate from the verified sender address.
EMAIL_REPLY_TO=""
# Web Push (Phase 7) — generate with: npx web-push generate-vapid-keys # Web Push — generate with: npx web-push generate-vapid-keys
# NEXT_PUBLIC_VAPID_PUBLIC_KEY must be the same value as VAPID_PUBLIC_KEY — # On a multi-business deployment these are normally managed centrally by
# it's duplicated because the browser needs it for pushManager.subscribe. # the Manager's Settings > Push Notifications page and pushed into every
# business's .env automatically — you don't need to fill these in by hand
# there. This single-business template still needs them set. The public
# key reaches the browser via GET /api/push/vapid-public-key at runtime,
# not a NEXT_PUBLIC_* build-time variable — see that route's own header
# for why (every business shares one prebuilt image).
# PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only). # PUSH_PROVIDER="web-push" to actually send; defaults to "console" (logs only).
PUSH_PROVIDER="console" PUSH_PROVIDER="console"
VAPID_PUBLIC_KEY="" VAPID_PUBLIC_KEY=""
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY="" VAPID_PRIVATE_KEY=""
VAPID_SUBJECT="mailto:owner@example.com" VAPID_SUBJECT="mailto:owner@example.com"
@@ -69,12 +82,12 @@ INSTALLATION_TOKEN=""
# Super Administrator (Phase 9) — platform-level account with every Owner # Super Administrator (Phase 9) — platform-level account with every Owner
# permission, provisioned from these env vars rather than the installation # permission, provisioned from these env vars rather than the installation
# wizard (see scripts/ensure-super-admin.ts, run on every container start). # wizard (see scripts/ensure-super-admin.ts, run on every container start).
# Has no StaffProfile by design: never appears in Team, the customer-facing # No password — signs in the same OTP way as everyone else, via a
# barber picker, or staff totals, and can never receive an appointment. All # one-time code emailed to SUPER_ADMIN_EMAIL. Has no StaffProfile by
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one # design: never appears in Team, the customer-facing barber picker, or
# entirely. Re-run to rotate the password (re-hashed every run). # staff totals, and can never receive an appointment. All three optional
# — leave SUPER_ADMIN_EMAIL unset to skip provisioning one entirely.
SUPER_ADMIN_EMAIL="" SUPER_ADMIN_EMAIL=""
SUPER_ADMIN_PASSWORD=""
SUPER_ADMIN_FIRST_NAME="" SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME="" SUPER_ADMIN_LAST_NAME=""
+10 -6
View File
@@ -1,12 +1,12 @@
# Shared infrastructure config — see docker-compose.infra.yaml for the # Shared infrastructure config — see stacks/infra/compose.yaml for the
# full setup sequence. Copy this to `.env.infra` (kept out of git — see # full setup sequence. Copy this to `stacks/infra/.env` (kept out of git
# .gitignore) and fill in real values. # — see .gitignore) and fill in real values.
# #
# docker network create next-chair-edge # docker network create next-chair-edge
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d # docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
# ── MySQL (shared server — one database per business, created manually # ── MySQL (shared server — one database per business, created manually
# per business, see docker-compose.infra.yaml's own header) ──────────── # per business, see stacks/infra/compose.yaml's own header) ────────────
MYSQL_ROOT_PASSWORD="replace-me" MYSQL_ROOT_PASSWORD="replace-me"
# Host-machine port MySQL is reachable on, e.g. for a GUI client over an # Host-machine port MySQL is reachable on, e.g. for a GUI client over an
# SSH tunnel — every business's app container always reaches it via the # SSH tunnel — every business's app container always reaches it via the
@@ -14,7 +14,11 @@ MYSQL_ROOT_PASSWORD="replace-me"
# this. # this.
MYSQL_HOST_PORT=3306 MYSQL_HOST_PORT=3306
# ── Traefik (reverse proxy + automatic TLS via Let's Encrypt) ────────── # ── Caddy (reverse proxy + automatic TLS via Let's Encrypt) ────────────
# Let's Encrypt sends renewal/expiry notices here — use a real address # Let's Encrypt sends renewal/expiry notices here — use a real address
# you actually check, not a placeholder. # you actually check, not a placeholder.
ACME_EMAIL="you@example.com" ACME_EMAIL="you@example.com"
# Leave these at 80/443 on a real deployment — only override for local
# testing on a machine already using those ports for something else.
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443
+19 -12
View File
@@ -26,14 +26,25 @@ dist/
.env.production .env.production
# per-deployment production env files (multi-business VPS setup — see # per-deployment production env files (multi-business VPS setup — see
# docker-compose.infra.yaml / docker-compose.<business>.yaml). Named # stacks/infra/compose.yaml / stacks/<business>/compose.yaml). Every real
# .env.business.* (not .env.<business> directly) specifically so this one # stack's .env lives at stacks/<name>/.env (one directory per stack,
# wildcard rule keeps covering every future business without another # same convention NextChair Manager's provisioning uses) — this one
# .gitignore edit. The template itself (.env.business.example) stays # wildcard rule covers every current and future stack without another
# tracked, same as .env.local.example above. # .gitignore edit. .env.infra.example and .env.business.example (the
.env.infra # templates you copy into each stacks/<name>/ directory) stay tracked,
.env.business.* # same as .env.local.example above.
!.env.business.example stacks/*/.env
# Real per-business Caddy site files (deployment-specific domains, not
# something to hand-author into the repo) — only the placeholder stays
# tracked.
stacks/infra/sites/*.caddy
!stacks/infra/sites/.gitkeep
# Manager's global platform settings (Settings > Email, etc. — see
# manager/src/lib/global-settings.ts) — contains an encrypted SMTP
# password, generated at runtime, never hand-authored into the repo.
stacks/manager/global-settings.json
# prisma # prisma
prisma/dev.db prisma/dev.db
@@ -57,10 +68,6 @@ yarn-error.log*
.idea/ .idea/
/generated/prisma /generated/prisma
.env.example
.env.local.example
.env.example
.env.example
.agents/ .agents/
.claude/ .claude/
+8
View File
@@ -43,6 +43,14 @@ ENV DATABASE_PORT="3306"
ENV DATABASE_NAME="build" ENV DATABASE_NAME="build"
ENV DATABASE_USER="build" ENV DATABASE_USER="build"
ENV DATABASE_PASSWORD="build" ENV DATABASE_PASSWORD="build"
# Skip `next build`'s TypeScript pass in the image. It's a redundant second
# check — `npx tsc --noEmit` already gates every change before it's built —
# and it's by far the heaviest step of the build: on a low-memory VPS it can
# swap for many minutes or get OOM-killed (exit 137). Type errors are still
# caught everywhere else; to type-check inside the image anyway, build with
# `docker build --build-arg SKIP_TYPECHECK=false .`
ARG SKIP_TYPECHECK=true
ENV SKIP_TYPECHECK=$SKIP_TYPECHECK
RUN npm run build RUN npm run build
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
+288 -1
View File
@@ -1,3 +1,290 @@
# NextChair # NextChair
Booking app as an alternative to Booksy Booking app as an alternative to Booksy
## VPS deployment (multi-business)
Everything below runs on a fresh VPS with Docker and the Compose plugin
already installed — no other prerequisite (no host Node.js/npm needed;
every command either uses Docker itself or plain shell tools). Every code
block is meant to be pasted as-is, in order.
### 0. One-time: clone the repo and generate your secrets
```bash
# STACKS_DIR later on must be this exact absolute path — every stack
# shells `docker compose` back out to the HOST's dockerd via a mounted
# socket, so the path only resolves to the same thing on both sides if
# it's cloned here and never moved.
git clone <your-repo-url> /opt/nextchair
cd /opt/nextchair
```
Generate the secrets you'll paste into the `.env` files in the steps
below. Run these now and keep the output somewhere safe (a password
manager) — you'll need each value again in a moment.
```bash
# MYSQL_ROOT_PASSWORD — goes into BOTH stacks/infra/.env and manager/.env
# (the same value in both places).
echo "MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)"
# MANAGER_SESSION_SECRET — manager/.env only.
echo "MANAGER_SESSION_SECRET=$(openssl rand -hex 32)"
# MANAGER_SETTINGS_ENCRYPTION_KEY — manager/.env only. Encrypts the
# SMTP/VAPID secrets you'll enter in the Manager's Settings page later.
# Losing/rotating this makes any already-saved secret there undecryptable,
# so store it as carefully as the other secrets on this page.
echo "MANAGER_SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)"
```
```bash
# MANAGER_PASSWORD_HASH — manager/.env only. Replace "your-password" with
# your chosen Manager login password first. Run via a throwaway container
# so this works with nothing but Docker installed on the host.
docker run --rm node:22-alpine sh -c \
"mkdir -p /tmp/gen && cd /tmp/gen && npm install bcryptjs --no-audit --no-fund >/dev/null 2>&1 && \
node -e \"const h=require('/tmp/gen/node_modules/bcryptjs').hashSync('your-password',12); console.log('MANAGER_PASSWORD_HASH=\\\"'+h.replace(/\\$/g,'\\$\\$')+'\\\"')\""
```
### 1. Shared infrastructure — network, MySQL and Caddy
```bash
docker network create next-chair-edge
cp .env.infra.example stacks/infra/.env
```
Edit `stacks/infra/.env`:
- `MYSQL_ROOT_PASSWORD` — the first secret you generated above.
- `MYSQL_HOST_PORT` — set this to `127.0.0.1:3306` so MySQL is only exposed on the VPS loopback interface, not publicly. You can still connect to it remotely through an SSH tunnel.
- `ACME_EMAIL` — a real address you check (Let's Encrypt renewal notices).
```bash
docker compose -f stacks/infra/compose.yaml --env-file stacks/infra/.env up -d
```
### 2. Build the shared application image
Every business (and no other step in this guide) runs this same image —
you only rebuild it here, once, and again whenever you ship a code
update (see "Updating" below).
```bash
docker build -t nextchair-app:latest .
```
### 3. The management UI (Manager)
This is what you'll use to add every business from here on.
```bash
cp manager/.env.example stacks/manager/.env
```
Edit `stacks/manager/.env`:
- `MANAGER_EMAIL` — the email you'll log in with.
- `MANAGER_PASSWORD_HASH` — the bcrypt hash generated in step 0.
- `MANAGER_SESSION_SECRET` and `MANAGER_SETTINGS_ENCRYPTION_KEY` — the two
other values generated in step 0.
- `MANAGER_PUBLIC_URL` — `https://` + the domain you'll point at this app
in the next step.
- `MYSQL_ROOT_PASSWORD` — same value as `stacks/infra/.env`.
- `STACKS_DIR` — same absolute path as step 1.
```bash
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
```
Point your chosen Manager domain's DNS (A/AAAA record) at the VPS, then
give it a Caddy site and reload:
```bash
cat > stacks/infra/sites/manager.caddy << 'EOF'
manager.example.com {
reverse_proxy next-chair-manager:3000
}
EOF
docker exec next-chair-caddy caddy reload --config /etc/caddy/Caddyfile
```
(Replace `manager.example.com` with your real domain — Caddy issues its
TLS certificate automatically on first request.)
### 4. Configure platform-wide settings (once)
Log into the Manager at `https://<your-manager-domain>` and open
**Settings**. Both sections are optional to fill in now, but doing it
before adding your first business means that business never needs its
own SMTP/push setup:
- **Email / SMTP** — your SMTP relay's host/port/username/password and a
from-address. Use **Send Test Email** to confirm it before saving for
real, then **Save Changes** to apply it to every business.
- **Push Notifications** — click **Generate VAPID Keys** once (a contact
email is enough to start). This is a one-time, platform-wide key pair —
see that section's own copy for what happens if you ever regenerate it.
### 5. Add your first business
Still in the Manager: **Add Business** → name, slug, domain. This creates
its database, generates its secrets, starts its container, and runs its
migrations for you — no manual per-business `.env` editing needed.
Point that business's own domain's DNS at the VPS (the Manager doesn't do
this for you), then finish its setup by visiting
`https://<business-domain>/install`.
Repeat this step for each additional business, whenever you want one.
### 6. Notification delivery (handled by the Manager)
Staff invitation emails, appointment reminders, push notifications,
loyalty reward emails, WhatsApp-reminder nudges and Google Reviews sync are
**not sent by the app itself** — it only queues them; something has to
trigger delivery on a schedule by calling two `CRON_SECRET`-gated
endpoints (`/api/cron/process-notifications`,
`/api/cron/sync-google-reviews` — see their route files for the reasoning).
Without a trigger, every business's queued notifications sit as `PENDING`
forever, even though the UI says "sent" the moment something is queued.
**The Manager's built-in scheduler does this for you** — nothing to
install on the host. Once the Manager is running it calls every
business's notification cron every 30 seconds (adjustable at Settings →
Monitoring & scheduler, 10 s – 1 hour) and the reviews sync once a day,
including for businesses added later. That page lists the last run of each
task per business, so you can confirm it's working.
Each business's `/api/health` also reports whether notifications are
actually going out (`status: "degraded"` plus the reasons, when the
processor hasn't run for 10 minutes, deliveries are stuck, or every delivery
in the last day failed), and the Manager emails you when that happens.
#### Removing the old crontab
If you set up the host crontab from an earlier version of this guide, remove
it once the Manager scheduler is confirmed working — running both is safe
(deliveries are claimed one at a time, so nothing is sent twice) but
pointless:
1. Open Settings → Monitoring & scheduler in the Manager and check that
"Process notifications" shows a recent successful run for each business.
2. On the VPS, as the user that owns `/opt/nextchair`, run `crontab -e` and
delete the two `process-cron.sh` lines (notifications and reviews).
3. Optionally delete `/opt/nextchair/nextchair-cron.log`.
#### Fallback: host crontab
Only if you run with `MANAGER_SCHEDULER_DISABLED=1`, or want an independent
trigger. [stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh)
calls both endpoints for **every** business under `STACKS_DIR` in one run.
Many minimal VPS images don't ship `cron` — install it first
(`sudo apt update && sudo apt install -y cron && sudo systemctl enable --now cron`),
then add this with `crontab -e` as the user that owns `/opt/nextchair`
(**not** `sudo crontab -e`, so the log file is writable):
```bash
# Deliver queued notifications every minute (the finest a crontab can do).
* * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /opt/nextchair/nextchair-cron.log 2>&1
# Refresh Google Reviews once a day.
0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /opt/nextchair/nextchair-cron.log 2>&1
```
A healthy run logs `<slug> process-notifications ok` for every business. Use
only one of the two mechanisms at a time as your normal setup.
## Updating after a code change
For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files.
First pull the latest code:
```bash
cd /opt/nextchair
git pull
```
Before rebuilding, check whether either example environment file has gained new settings since your last deployment:
```bash
# Review only — do not copy these files over your existing production .env files.
diff -u stacks/infra/.env .env.infra.example || true
diff -u stacks/manager/.env manager/.env.example || true
```
If a new variable has been added, copy **only that variable** into the corresponding production `.env` and give it an appropriate value. Keep all existing passwords, secrets and generated values unchanged.
Then rebuild the one shared image every business uses:
```bash
cd /opt/nextchair
docker build -t nextchair-app:latest .
```
Then, for **each** business: use its **Update** button in the Manager UI,
or run this directly:
```bash
cd /opt/nextchair/stacks/<slug>
docker compose --env-file .env up -d --force-recreate
```
Either way this recreates that business's container against the freshly
built image — and its own startup automatically runs
`prisma migrate deploy`, so any new database migration is applied on the
same step. No separate migration command needed.
If the code change touched the **Manager app itself** (anything under
`manager/`), rebuild/recreate the Manager too:
```bash
cd /opt/nextchair
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
```
If both the business app and Manager changed, do both update steps.
## Upgrading from a version that included Dockge (one-time)
Dockge used to run alongside NextChair Manager as a separate stack
management UI. It's no longer part of the shared infrastructure stack —
Manager is the only tool needed to provision and manage business stacks,
so the two were redundant. If your server was set up before this change,
existing tenant databases, tenant containers, business Compose files, and
the global SMTP/VAPID settings are all unaffected — this only removes the
old Dockge container.
Pull the change, then re-apply the infra stack with `--remove-orphans` so
Docker removes the container that's no longer defined in
`stacks/infra/compose.yaml`:
```bash
cd /opt/nextchair
git pull
docker compose \
-f stacks/infra/compose.yaml \
--env-file stacks/infra/.env \
up -d --remove-orphans
```
`--remove-orphans` only removes containers Compose no longer recognizes
(the old `next-chair-dockge` container) — it does not touch Caddy, MySQL,
or their volumes.
This does **not** delete Dockge's own data volume
(`next-chair-dockge-data`) — Docker never removes volumes on its own, and
this command has no reason to either. Once you've confirmed Caddy, MySQL
and every business are still healthy, you can remove that now-unused
volume by hand:
```bash
docker volume rm next-chair-dockge-data
```
Finally, `STACKS_DIR` and `DOCKGE_HOST_PORT` in `stacks/infra/.env` were
only ever used by Dockge and are safe to delete from that file — nothing
else in the infra stack reads them. (Manager keeps its own separate
`STACKS_DIR` in `stacks/manager/.env`; leave that one alone.)
-63
View File
@@ -1,63 +0,0 @@
name: next-chair-blake-heritage
# One business's app container on the shared multi-business VPS setup —
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
# to, and that file's header for the exact one-time setup steps (create
# this business's database, copy .env.business.example) needed before
# this file can start.
#
# docker compose -f docker-compose.blake-heritage.yaml --env-file .env.business.blake-heritage up -d --build
#
# `docker-compose.marios.yaml` is the identical shape for the second
# business — copy this file's pattern again for any future business.
services:
next-chair-blake-heritage:
build:
context: .
dockerfile: Dockerfile
# Builds the image fresh here. Once there are more than a couple of
# businesses, it's worth building once (`docker build -t
# nextchair-app:latest .`) and swapping this `build:` block for
# `image: nextchair-app:latest` in every business's file instead, so
# a code update doesn't mean rebuilding the identical image N times —
# not necessary to start with.
container_name: next-chair-blake-heritage
restart: unless-stopped
env_file:
- .env.business.blake-heritage
volumes:
- next-chair-media-blake-heritage:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.http.routers.blake-heritage.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.blake-heritage.entrypoints=websecure"
- "traefik.http.routers.blake-heritage.tls.certresolver=letsencrypt"
- "traefik.http.services.blake-heritage.loadbalancer.server.port=3000"
networks:
- next-chair-edge
volumes:
next-chair-media-blake-heritage:
name: next-chair-media-blake-heritage
networks:
next-chair-edge:
name: next-chair-edge
external: true
-103
View File
@@ -1,103 +0,0 @@
name: next-chair-infra
# Shared infrastructure for a multi-business VPS deployment: one Traefik
# reverse proxy (routes each business's domain to its own app container,
# handles TLS via Let's Encrypt) and one MySQL server shared by every
# business — a separate database + user per business, NOT one shared
# schema (see step 1 below).
#
# Deliberately separate from docker-compose.local.yaml, which is the
# single-business local/Docker-Desktop setup and stays untouched by this.
#
# ── One-time setup ───────────────────────────────────────────────────────
# docker network create next-chair-edge
# cp .env.infra.example .env.infra (then fill in real values)
# docker compose -f docker-compose.infra.yaml --env-file .env.infra up -d
#
# ── Per business, after infra is running (see docker-compose.<business>
# .yaml — this repo ships one per business, e.g. docker-compose.blake-
# heritage.yaml) ────────────────────────────────────────────────────────
# 1. Create that business's database + user on the shared MySQL server.
# There's no automatic way to do this per business via env vars —
# the mysql:8.0 image's MYSQL_DATABASE/USER/PASSWORD vars only
# provision ONE database at first boot, and /docker-entrypoint-
# initdb.d scripts only ever run once against an empty data
# directory — so this one step is manual, run once per business:
# docker exec -it next-chair-mysql mysql -uroot -p
# CREATE DATABASE blake_heritage CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
# CREATE USER 'blake_heritage'@'%' IDENTIFIED BY 'choose-a-strong-password';
# GRANT ALL PRIVILEGES ON blake_heritage.* TO 'blake_heritage'@'%';
# FLUSH PRIVILEGES;
# 2. Copy .env.business.example to .env.business.<slug>, fill it in
# with the credentials just created and that business's domain.
# 3. docker compose -f docker-compose.<business>.yaml --env-file .env.business.<slug> up -d --build
services:
traefik:
image: traefik:v3.2
container_name: next-chair-traefik
restart: unless-stopped
command:
- "--providers.docker=true"
# Only containers with an explicit traefik.enable=true label are
# ever routed to — nothing becomes reachable by accident just for
# sitting on the shared network.
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
# Plain HTTP always redirects to HTTPS — no business is ever served
# unencrypted, even before you've thought about it per-business.
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
# Read-only — Traefik only needs to observe container labels/state
# to discover routes, never control the daemon.
- /var/run/docker.sock:/var/run/docker.sock:ro
- next-chair-traefik-certs:/letsencrypt
networks:
- next-chair-edge
next-chair-mysql:
image: mysql:8.0
container_name: next-chair-mysql
restart: unless-stopped
env_file:
- .env.infra
# No MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD here deliberately — this
# server hosts one database per business (see step 1 above), not one
# shared schema. Only MYSQL_ROOT_PASSWORD (in .env.infra) is required
# for the image to boot.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_ai_ci
ports:
# Host-machine access (e.g. a GUI client over an SSH tunnel) — every
# business's app container reaches this over the shared Docker
# network via the service name `next-chair-mysql`, never this.
- "${MYSQL_HOST_PORT:-3306}:3306"
volumes:
- next-chair-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-change-me-too}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
networks:
- next-chair-edge
volumes:
next-chair-mysql-data:
name: next-chair-mysql-data
next-chair-traefik-certs:
name: next-chair-traefik-certs
networks:
next-chair-edge:
name: next-chair-edge
external: true
-63
View File
@@ -1,63 +0,0 @@
name: next-chair-marios
# One business's app container on the shared multi-business VPS setup —
# see docker-compose.infra.yaml for the shared MySQL/Traefik this attaches
# to, and that file's header for the exact one-time setup steps (create
# this business's database, copy .env.business.example) needed before
# this file can start.
#
# docker compose -f docker-compose.marios.yaml --env-file .env.business.marios up -d --build
#
# `docker-compose.blake-heritage.yaml` is the identical shape for the
# first business — copy this file's pattern again for any future business.
services:
next-chair-marios:
build:
context: .
dockerfile: Dockerfile
# Builds the image fresh here. Once there are more than a couple of
# businesses, it's worth building once (`docker build -t
# nextchair-app:latest .`) and swapping this `build:` block for
# `image: nextchair-app:latest` in every business's file instead, so
# a code update doesn't mean rebuilding the identical image N times —
# not necessary to start with.
container_name: next-chair-marios
restart: unless-stopped
env_file:
- .env.business.marios
volumes:
- next-chair-media-marios:/app/storage/media
healthcheck:
# No curl/wget in the node:22-alpine base image — a plain Node
# request avoids adding a package just for this. See
# src/app/api/health/route.ts: healthy as soon as the database is
# reachable, even before installation completes.
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
labels:
- "traefik.enable=true"
- "traefik.http.routers.marios.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.marios.entrypoints=websecure"
- "traefik.http.routers.marios.tls.certresolver=letsencrypt"
- "traefik.http.services.marios.loadbalancer.server.port=3000"
networks:
- next-chair-edge
volumes:
next-chair-media-marios:
name: next-chair-media-marios
networks:
next-chair-edge:
name: next-chair-edge
external: true
+55
View File
@@ -0,0 +1,55 @@
# Calendar
A living reference for the day calendar on `/staff` (a barber's own day) and
`/owner/calendar` (everyone, one column per barber). Both render
`src/features/staff/components/day-calendar.tsx`.
## What it's built on
[react-big-calendar](https://github.com/jquense/react-big-calendar) (MIT) with
its drag-and-drop addon, restyled to match the app in `day-calendar.css`
(scoped under `.day-cal`). It was chosen over DayPilot Lite mainly because
per-barber columns, a current-time line and sideways scrolling on a phone all
work out of the box; FullCalendar and Schedule-X put per-resource columns
behind a paid licence.
Only the day view is used. The agenda (list) view is a separate component.
## Time zones — read this before touching it
The library works in JavaScript `Date`s, which carry the **browser's** time
zone; the app's scheduling data is **business-local minutes since midnight on
a business-local date**. So the calendar never sees a real instant: times go in
as "floating" dates (minutes laid onto one fixed day — `calendar-time.ts`) and
a drop comes back out as plain minutes. The real date and the business time
zone are applied on the server in `moveCalendarAppointmentAction`.
## Drag to reschedule
Turned on per page with the `reschedule` prop (`"owner"` or `"staff"`).
- A drop **never saves on its own.** It opens a confirmation ("From 11:00 AM
with Leo → To 1:00 PM with Leo… the customer will be notified"), because a
reschedule emails the customer.
- `"owner"`: any appointment in the business, and it can be dropped into
another barber's column (a reassignment). `"staff"`: their own bookings, along
the time axis only.
- Only `pending_approval`, `confirmed` and `arrived` bookings can be moved —
the same statuses `rescheduleBooking` accepts. Breaks, time off, and finished
or in-progress bookings are not draggable. Resizing (changing the length) is
off: duration comes from the services.
- **The server is the gate.** `moveCalendarAppointmentAction` delegates to the
existing `ownerRescheduleAppointmentAction` / `rescheduleStaffAppointmentAction`,
so working hours, breaks, overlaps, the staff member's services, the business's
minimum-notice rule, the audit log, notifications, reminders and Google
Calendar sync all behave exactly as they do from the appointment screen. A
rejected move shows the reason inside the dialog and nothing is saved.
- The calendar itself does not stop you dropping onto a break or outside
someone's hours — it just fails the server check.
## Phones
Columns keep a 190px minimum and the grid scrolls sideways, with the hour
labels pinned on the left and the header row scrolling with it. Dragging was
verified with a mouse only; the library has touch handlers but this has not been
tried on a real touch screen. The appointment screen's Reschedule always works.
+291
View File
@@ -0,0 +1,291 @@
# Notifications reference
A living reference for every notification NextChair sends — what it is,
who gets it, on which channels, and whether the recipient can turn it
off. Unlike the other files in `docs/` (dated phase summaries), this one
is meant to be kept current as notifications change; if you add, remove,
or retarget one, update this file in the same change.
## Two separate mechanisms
Most of what's on this page goes through the **queued notification
system**: an event happens, a `Notification` + one `NotificationDelivery`
row per applicable channel is created immediately, but the actual
EMAIL/PUSH send only happens when the background processor runs (see
`src/services/notifications/reminder-worker.ts`, triggered by
`POST /api/cron/process-notifications` — see the root README's
"Notification delivery" section for how that's scheduled on a real
deployment). `IN_APP` deliveries have no send step; they're just a
database row the notification bell/centre reads directly.
**One-time codes** (below, in their own section) are different — they're
sent synchronously, the moment they're requested, with no queue at all.
Either way, nothing actually reaches an inbox unless that business has
`EMAIL_PROVIDER=brevo` configured (Manager's Settings → Email, or a
business's own `.env`) — with the default `EMAIL_PROVIDER=console`,
every send just logs to the container's console instead.
## Channels
- **EMAIL** — sent only if the recipient has a resolvable email address
(a registered user's `User.email`, or a guest customer's
`CustomerProfile.email`).
- **PUSH** — sent only if the recipient has at least one active (not
revoked) browser push subscription.
- **IN_APP** — only meaningful for a recipient with a real account
(`recipientUserId` set) — a guest customer never gets one, since they
have nothing to sign into.
## Preferences — who can turn what off, and what's on by default
See `src/services/notifications/types.ts`'s `PREFERENCE_CONTROLLED_TYPES`
— this is the single source of truth for which types have a toggle at
all; everything not listed there is **required** (always sent on every
channel it has a valid target for, no toggle anywhere — see "Fixed
no-email types" below for a separate, smaller mechanism that trims one
channel off a handful of these without making them toggleable).
- **Customer-facing**: only `BOOKING_REMINDER` is optional (and only
controls the customer reminder — see "Reminder timing"). Everything else
(confirmations, changes, cancellations, loyalty) always notifies the
customer on every resolvable channel — not finding out your
appointment was cancelled is a real risk, not just noise.
- **Staff- and owner-facing**: every type is optional. The owner of a
business is commonly also its only barber — one `StaffProfile`, one
account — so without this, a single new booking would unconditionally
notify that one person twice (`STAFF_NEW_APPOINTMENT` +
`OWNER_NEW_BOOKING`) on every channel, including in-app, with no way to
quiet either copy. (Cross-role dedup, below, now also prevents the
literal duplicate even when both happen to be enabled.)
Toggles live at **Settings → Notifications** — customers see one
"Appointment reminders" group; staff see their own six groups, plus five
more owner-only groups if they hold `VIEW_REPORTING` (i.e. are actually
an owner — see `src/services/notifications/recipients.ts`'s
`getOwnerUserIds`, which checks the same permission).
**Defaults**: `types.ts`'s `defaultPreferenceIsEnabled(type)` is the
single source of truth for what a toggle shows/does before anyone has
ever saved a preference row for it — used both by the actual send
decision (`notification.service.ts`'s `isChannelEnabled`) and by the
Settings pages themselves, so a toggle's initial on/off state always
matches what will actually happen. Once a user explicitly saves a
preference, that stored value always wins, in either direction — this
function only governs the "nobody has touched this yet" fallback.
Most staff/owner types default to **on**. These default to **off**
(on every channel)
instead — a solo owner-barber otherwise gets emailed/pushed/in-app'd for
everything the moment they sign in, with no gentle on-ramp:
| Type | Why |
|---|---|
| `STAFF_NEW_APPOINTMENT` | Noisy for a busy barber; `STAFF_APPOINTMENT_CANCELLED` (which stays on) is the one that actually needs acting on. |
| `STAFF_APPOINTMENT_RESCHEDULED` | Same reasoning. |
| `OWNER_NEW_BOOKING` | Same as STAFF_NEW_APPOINTMENT, from the owner's side. |
| `OWNER_NO_SHOW` | The barber who marked it already knows — there's no staff-facing equivalent by design. |
Defaults can also differ **per channel**, not just per type
(`DEFAULT_DISABLED_CHANNELS` in `types.ts`). Currently one:
`STAFF_APPOINTMENT_REMINDER` is **on** for push and in-app but **off** for
email — a barber gets a nudge on their phone two hours out without a second
email for something that's about to happen anyway. Email is opt-in under
Settings → Notifications.
## Fixed no-email types
A few types are `EMAIL` never, always, and unconditionally — not a
default that can be toggled back on, because the type either has no
preference toggle at all or (for `BOOKING_BARBER_CHANGED`) the decision
is per-booking context a stored preference can't express. This is
`NotifyParams.skipEmail` in `notification.service.ts` — a single boolean
the caller passes, checked in `resolveChannels()`'s EMAIL branch
independently of the preference system. PUSH/IN_APP are unaffected in
every case below.
| Type | Condition |
|---|---|
| `BOOKING_COMPLETED` | Always — a "thanks for visiting" email after every single cut was more noise than value. |
| `LOYALTY_STAMP_ADDED` | Always — a stamp is added on nearly every visit. |
| `LOYALTY_REWARD_REDEEMED` | Always — the customer is present when a reward is redeemed; an email receipt afterward added little. |
| `BOOKING_BARBER_CHANGED` | Only when the customer originally booked without picking a specific staff member ("any available barber" — see `Appointment.staffSelectedByCustomer`) and hasn't been reassigned before. Someone who booked "any available" is less likely to care who they end up with; someone who picked this barber specifically still gets emailed. |
## Cross-role deduplication
A person who holds two roles for the same booking (most commonly a solo
owner-barber: one `User`, one `StaffProfile`, and a role with
`VIEW_REPORTING`) can be the target of two *equivalent* notifications for
one underlying event — e.g. a new booking fires both
`STAFF_NEW_APPOINTMENT` (they're the assigned staff) and
`OWNER_NEW_BOOKING` (they're also an owner). Since both resolve to the
identical `recipientUserId`, `notification.service.ts`'s `notify()`
detects this and suppresses the less-specific one — on every channel,
not just email — rather than relying on the person to manually mute one
side.
`EQUIVALENCE_GROUPS` in that file lists which types are considered the
same underlying event, and their relative priority:
| Group | Types (higher priority first) |
|---|---|
| `new_appointment` | `STAFF_NEW_APPOINTMENT` > `OWNER_NEW_BOOKING` |
| `appointment_cancelled` | `STAFF_APPOINTMENT_CANCELLED` > `OWNER_BOOKING_CANCELLED` |
When an equivalent, higher-or-equal-priority notification already exists
for the same `(businessId, appointmentId, recipientUserId)`, the new one
is still created as a `Notification` row (audit trail, keeps
`idempotencyKey` semantics intact) but with **zero** deliveries — nothing
sends on any channel. This relies on call order: every real call site
(`booking-notifications.ts`) already notifies the STAFF_* type before
looping the OWNER_* type for the same event, so the more specific one is
always created first. Only types explicitly listed in
`EQUIVALENCE_GROUPS` ever participate — e.g. `OWNER_NO_SHOW` has no staff
equivalent and is never touched by this, and any future owner-only
*actionable* type (e.g. "approve this booking") would likewise stay
untouched simply by not being added to the map. Unrelated events for the
same appointment (a confirmation, then later a cancellation) are never
conflated — they're different types in different groups, or no group at
all.
## Reminder timing
`src/services/scheduling/reminder-scheduling.ts` schedules reminders at
fixed offsets before the appointment, independently for the customer and
the assigned staff member:
- **Customer** (`BOOKING_REMINDER`): one reminder, **24 hours** before. If
the booking was made too late for that (a same-day or next-morning
booking), they get one **2 hours** before instead — never both, and never
nothing, unless the appointment is under 2 hours away.
- **Staff** (`STAFF_APPOINTMENT_REMINDER`): one reminder, **2 hours**
before. No 24-hour one. Push and in-app on by default, email opt-in —
see "Defaults" above.
- **Owner**: no reminders.
A reminder time that's already in the past when the booking is
made/approved is never scheduled retroactively.
**In-app reminders are held back until they're due.** Every scheduled
notification's in-app copy is created `SCHEDULED`, not `SENT`, so it stays
out of the notification bell and unread count until the background
processor releases it at the reminder time (`releaseDueInAppReminders` in
`reminder-worker.ts`). Cancelling or rescheduling cancels it along with the
email/push copies, so a stale reminder never appears.
**Stale-send guard.** Before sending (or releasing) a reminder, the worker
checks the appointment is still `CONFIRMED` and hasn't started; if not, the
delivery is marked `CANCELLED` (not `FAILED`, so it doesn't count toward the
owner's delivery-failure alert). This is a backstop for the processor being
down long enough that a "24 hours before" reminder would otherwise arrive
after the appointment.
**Guest links.** A guest has no account, so every customer-facing link in
their emails carries an access token (`customerAppointmentLink` in
`guest-access.service.ts`). The booking-time token is random and only its
hash is stored, so later emails use a token *derived* from the appointment
id and `AUTH_SECRET` instead; `validateGuestAccessToken` accepts either, and
revoking a guest appointment's access kills both.
---
## Customer-facing
| Type | Fires when | Default subject | Email |
|---|---|---|---|
| `BOOKING_CONFIRMED` | A new booking is created and doesn't need approval | "Booking confirmed" | Always |
| `BOOKING_PENDING_APPROVAL` | A new booking is created but the business requires approval first | "Booking request received" | Always |
| `BOOKING_APPROVED` | An owner approves a pending booking | "Booking approved" | Always |
| `BOOKING_RESCHEDULED` | The appointment's time changes (including an owner reassigning staff, which also triggers a reschedule) | "Booking rescheduled" | Always |
| `BOOKING_CANCELLED` | The appointment is cancelled, by anyone | "Booking cancelled" (carries a "This was a late cancellation." note when applicable — not a separate type) | Always |
| `BOOKING_BARBER_CHANGED` | An owner reassigns the appointment to a different staff member | "Your barber has changed" | Only if the customer originally picked a specific staff member — see "Fixed no-email types" |
| `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" | Never |
| `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment (2 hours before if booked with less than 24 hours' notice) | "Upcoming appointment reminder" | Default on |
| `LOYALTY_STAMP_ADDED` | A loyalty stamp is added to their card | "You've earned a loyalty stamp" | Never |
| `LOYALTY_REWARD_EARNED` | Their stamp count reaches the reward threshold | "You've earned a reward" | Always |
| `LOYALTY_REWARD_EXPIRING` | A reward's expiry is within 7 days — sent once when it enters that window, and once more when it's down to its last day (two at most per reward) | "Your reward is expiring soon" | Always |
| `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never |
## Staff-facing
| Type | Fires when | Default subject | Default |
|---|---|---|---|
| `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" | Off |
| `STAFF_APPOINTMENT_RESCHEDULED` *(optional)* | One of their appointments is rescheduled | "Appointment rescheduled" | Off |
| `STAFF_APPOINTMENT_CANCELLED` *(optional)* | One of their appointments is cancelled | "Appointment cancelled" | On |
| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 2 hours before their appointment | "Upcoming appointment reminder" | In-app and push on, email off |
| `STAFF_SCHEDULE_CHANGED` *(optional)* | An **owner** edits their working hours/breaks/time-off on their behalf — never fires from the self-service editors, since notifying someone about their own edit would just be noise | "Your schedule was updated" | On |
| `STAFF_INVITATION_RECEIVED` *(optional)* | They're added as a new team member | "You've been invited to join `{business}`" | On |
| `STAFF_APPOINTMENT_CHECKED_IN` | Defined in the schema, has a recipient assigned — **but nothing in the codebase currently triggers it.** Preference-controlled for forward-compatibility, but has no toggle in the UI since there's nothing to preview. Left dormant on purpose | — | — |
## Owner-facing
Sent to *every* active staff member whose role holds `VIEW_REPORTING` —
that can be more than one person; there's no single "the owner" column
anywhere in the schema.
| Type | Fires when | Default subject | Default |
|---|---|---|---|
| `OWNER_NEW_BOOKING` *(optional)* | A new booking is made anywhere in the business | "New booking" | Off |
| `OWNER_BOOKING_CANCELLED` *(optional)* | A booking is cancelled anywhere in the business | "Booking cancelled" | On |
| `OWNER_NO_SHOW` *(optional)* | A customer is marked as a no-show (staff-facing equivalent doesn't exist — the barber who marked it already knows) | "No-show recorded" | Off |
| `OWNER_STAFF_INVITATION_ACCEPTED` *(optional)* | An invited staff member verifies their email and joins | "Invitation accepted" | On |
| `OWNER_REPEATED_DELIVERY_FAILURE` *(optional)* | The background processor itself detects repeated delivery failures that day — a self-monitoring alert, not triggered by a domain event. At most once per business per day | "Some notifications failed to send" | On |
---
## One-time codes (not part of the system above)
Sent immediately and synchronously — see `src/lib/auth/otp.service.ts`.
Not queued, not preference-controlled, no in-app record.
| Purpose | Sent to | When |
|---|---|---|
| `login` | The email being signed in with | Every sign-in attempt |
| `register` | The email registering | Customer self-registration |
| `staff-invite` | The invited email | Accepting a staff invitation (`/invite/accept`) |
| `install` | The administrator's email | The install wizard's administrator-verification step |
| `email-change` | The **new** email address | A signed-in user changing their account email |
## Platform-level (Manager, not tied to any business)
- **Settings → Email → Send Test Email** — a manual, one-off admin action
in the Manager app, unrelated to any business's own notification queue.
See `manager/src/lib/test-email.ts`.
---
## Where things live, if you're changing any of this
- **What exists / who it's for**: `prisma/schema.prisma`'s
`NotificationType` enum, and `RECIPIENT_KIND` in
`src/services/notifications/types.ts`.
- **Whether it's optional, and its default**: `PREFERENCE_CONTROLLED_TYPES`
and `defaultPreferenceIsEnabled()`, same file.
- **Fixed no-email types**: `NotifyParams.skipEmail` in
`notification.service.ts`, set at the relevant call site in
`booking-notifications.ts`/`loyalty-notifications.ts`.
- **Cross-role dedup**: `EQUIVALENCE_GROUPS` in `notification.service.ts`.
- **Whether the customer picked a specific barber originally**:
`Appointment.staffSelectedByCustomer`, set once at booking creation in
`booking.service.ts`'s `createBooking()`, read at reassignment time in
`src/features/owner/actions/appointments.ts`.
- **When it actually fires**: `src/services/scheduling/booking-notifications.ts`,
`reminder-scheduling.ts`, `src/services/loyalty/loyalty-notifications.ts`,
`src/services/staff/staff-notifications.ts`,
`src/services/staff/staff-invitation.service.ts`, and
`reminder-worker.ts` (for the self-monitoring alert).
- **Channel resolution / preference enforcement**:
`src/services/notifications/notification.service.ts`'s
`resolveChannels`/`isChannelEnabled`.
- **The actual send**: `src/services/notifications/providers/` (Brevo/
console email, web-push/console push) plus `reminder-worker.ts`, which
processes the queue.
- **Settings UI**: `src/features/staff/components/notification-preferences-form.tsx`
(shared by customer/staff/owner), wired up per audience in
`src/app/(customer)/settings/notifications/page.tsx` and
`src/app/(staff)/staff/settings/notifications/page.tsx`.
- **Guest-to-account merge** (what happens to a guest's history when they
later sign up): `src/services/customer/guest-merge.service.ts`, called from
`authorizeCredentials` (OTP sign-in), `src/auth.ts`'s Google `signIn`
event, and `createCustomerByStaff`.
+3 -3
View File
@@ -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. 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. 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. 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 ## 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. - **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. - **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. - **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 ## 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. - 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. - 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. - Loyalty UI, staff/owner-facing screens, notifications, and payments are all still untouched, exactly as scoped.
+4 -4
View File
@@ -157,16 +157,16 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 19. Known limitations ## 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. - **Working-hours editing had no conflict detection** (both the self-service and owner-on-behalf editors) — **closed in Phase 7** ("Working-hours conflict review": `findWorkingHoursConflicts` / `setWorkingHours({ acknowledgeConflicts })` in `staff.service.ts`, the conflict list in `working-hours-form.tsx`, covered by `staff.service.integration.test.ts`). Kept here only as history.
- **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. - **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. - **"Requires staff approval to redeem" and "Apply reward automatically" were removed from the loyalty settings form** — neither did anything (redemption is always staff-initiated and gated by `MANAGE_LOYALTY_STAMPS`). The two `LoyaltyProgramme` columns still exist, unused.
- **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. - **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.
- **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary. - **Notification delivery, payments, deposits, advanced financial reporting, payroll, multi-business/multi-location, PWA, and offline** remain entirely out of scope, per the brief's own boundary.
## 20. Review items for Phase 7 ## 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. - 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. - 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. - 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.
+37 -28
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers"; import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers";
/** /**
@@ -50,7 +51,7 @@ test.describe("customer booking journeys", () => {
}); });
test("2. registered customer books an appointment", async ({ page }) => { test("2. registered customer books an appointment", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/book"); await page.goto("/book");
await completeBookingFlow(page, { await completeBookingFlow(page, {
@@ -65,29 +66,37 @@ test.describe("customer booking journeys", () => {
await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible(); 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, SEEDED.registeredCustomer.password); await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/appointments"); await page.goto("/appointments");
// Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed. // One list, no tabs.
await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active"); await expect(page.getByRole("tab")).toHaveCount(0);
const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name);
// 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).toBeVisible();
await expect(upcomingSeeded.getByText("Confirmed")).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. // Seeded: a COMPLETED Haircut & Style with Leo Vance — same list, Book Again instead.
await page.getByRole("tab", { name: "Past" }).click(); const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
await expect(pastSeeded).toBeVisible(); await expect(pastSeeded).toBeVisible();
await expect(pastSeeded.getByText("Completed")).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. // Newest first: the upcoming appointment is above the completed one.
await page.getByRole("tab", { name: "Cancelled" }).click(); const upcomingBox = await upcomingSeeded.boundingBox();
await expect(page.getByText("No cancelled appointments")).toBeVisible(); const pastBox = await pastSeeded.boundingBox();
expect(upcomingBox!.y).toBeLessThan(pastBox!.y);
}); });
test("4. customer reschedules an appointment", async ({ page }) => { test("4. customer reschedules an appointment", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await signIn(page, SEEDED.registeredCustomer.email);
// Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests. // Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests.
await page.goto("/book"); await page.goto("/book");
@@ -113,17 +122,17 @@ test.describe("customer booking journeys", () => {
}); });
test("5. customer cancels an appointment", async ({ page }) => { test("5. customer cancels an appointment", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/book"); await page.goto("/book");
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name }); await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name });
await page.goto("/appointments"); 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 expect(upcomingCard).toBeVisible();
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click(); await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
// Scoped to the currently-open <dialog> — the Upcoming tab can have several // Scoped to the currently-open <dialog> — the list can have several
// cancellable cards, each with its own (closed) dialog in the DOM. // cancellable cards, each with its own (closed) dialog in the DOM.
const dialog = page.locator("dialog[open]"); const dialog = page.locator("dialog[open]");
await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible(); await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible();
@@ -133,21 +142,21 @@ test.describe("customer booking journeys", () => {
await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible(); await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible();
await dialog.getByRole("button", { name: "Done" }).click(); await dialog.getByRole("button", { name: "Done" }).click();
// Cancellation never deletes — it moves from Upcoming to the Cancelled tab. // Cancellation never deletes — the same card stays in the list, now with a Cancelled pill
await expect(findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name)).toHaveCount(0); // and Book Again instead of Reschedule/Cancel.
await page.getByRole("tab", { name: "Cancelled" }).click(); const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
await expect(cancelledCard).toBeVisible(); await expect(cancelledCard).toBeVisible();
await expect(cancelledCard.getByText("Cancelled")).toBeVisible(); await expect(cancelledCard.getByText("Cancelled")).toBeVisible();
await expect(cancelledCard.getByRole("button", { name: "Book Again" })).toBeVisible();
await expect(cancelledCard.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
}); });
test("6. customer books again from history", async ({ page }) => { test("6. customer books again from history", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/appointments"); await page.goto("/appointments");
// Seeded COMPLETED appointment (Haircut & Style with Leo Vance). // Seeded COMPLETED appointment (Haircut & Style with Leo Vance).
await page.getByRole("tab", { name: "Past" }).click(); const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
await completedCard.getByRole("button", { name: "Book Again" }).click(); await completedCard.getByRole("button", { name: "Book Again" }).click();
// Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time. // Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time.
@@ -160,13 +169,13 @@ test.describe("customer booking journeys", () => {
// A brand new appointment now exists alongside the historical one — the completed appointment is untouched. // A brand new appointment now exists alongside the historical one — the completed appointment is untouched.
await page.goto("/appointments"); await page.goto("/appointments");
await expect(findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name)).toBeVisible();
await page.getByRole("tab", { name: "Past" }).click();
await expect(completedCard).toBeVisible(); await expect(completedCard).toBeVisible();
}); });
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => { test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/book"); await page.goto("/book");
await completeBookingFlow(page, { await completeBookingFlow(page, {
@@ -197,7 +206,7 @@ test.describe("customer booking journeys", () => {
test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => { test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => {
// Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) — // Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) —
// must run before owner-journeys.spec.ts's journey 10, which redeems it. // must run before owner-journeys.spec.ts's journey 10, which redeems it.
await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password); await signIn(page, SEEDED.secondCustomer.email);
await page.goto("/home"); await page.goto("/home");
await expect(page.getByText(/Reward available/)).toBeVisible(); await expect(page.getByText(/Reward available/)).toBeVisible();
@@ -215,7 +224,7 @@ test.describe("customer booking journeys", () => {
}); });
test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => { test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/home"); await page.goto("/home");
// The description stays visible on the Services tab row — the Book button doesn't hide it. // The description stays visible on the Services tab row — the Book button doesn't hide it.
-1
View File
@@ -25,7 +25,6 @@ export default function globalSetup() {
// when the caller hasn't already set their own (same fallback pattern // when the caller hasn't already set their own (same fallback pattern
// as AUTH_SECRET in playwright.config.ts). // as AUTH_SECRET in playwright.config.ts).
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk", SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123",
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super", SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin", SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
}; };
+41 -6
View File
@@ -1,20 +1,26 @@
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */ /**
* Seeded in prisma/seed.ts — see that file for the full fixture set this
* suite relies on. No `password` fields here anymore: sign-in is OTP-only
* now (see signIn() below), so a seeded user's passwordHash (still written
* by prisma/seed.ts / ensure-super-admin.ts) is vestigial and unused by
* any auth path.
*/
export const SEEDED = { export const SEEDED = {
registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" }, registeredCustomer: { email: "alex.chen@example.com", name: "Alex Chen" },
// Seeded right at their loyalty reward threshold (10/10 stamps, one // Seeded right at their loyalty reward threshold (10/10 stamps, one
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's // AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
// journey 10 redeems this exact reward, so any test relying on it still // journey 10 redeems this exact reward, so any test relying on it still
// being AVAILABLE must run before that (customer-journeys.spec.ts runs // being AVAILABLE must run before that (customer-journeys.spec.ts runs
// first in the suite, so this is safe there). // first in the suite, so this is safe there).
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" }, secondCustomer: { email: "sam.osei@example.com", name: "Sam Osei" },
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" }, owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not // Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile // prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose. // (see isSuperAdmin) and stays out of the dev/demo seed data on purpose.
superAdmin: { email: "superadmin@blakeheritage.co.uk", password: "password123", name: "Super Admin" }, superAdmin: { email: "superadmin@blakeheritage.co.uk", name: "Super Admin" },
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" }, barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" }, barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
services: { services: {
@@ -24,7 +30,32 @@ export const SEEDED = {
}, },
}; };
export async function signIn(page: Page, email: string, password: string): Promise<void> { /**
* Reads a just-sent OTP code back out of the dev server's ConsoleEmailProvider
* capture buffer via /api/test/otp-code (see that route's own header — it
* 404s unless E2E_TEST_MODE is set, which playwright.config.ts's webServer
* does for exactly this). Polls briefly since the email is sent by a
* Server Action that may not have resolved the instant the client-side
* step transition happens.
*
* `sentAfter` (epoch ms) must be captured right before whatever action
* triggers the send — the same seeded users sign in repeatedly across this
* suite, so without it this could hand back a *previous*, already-consumed
* code still sitting in the buffer instead of the one just requested (see
* that route's own `since` param doc for the full reasoning).
*/
async function fetchOtpCode(page: Page, email: string, sentAfter: number): Promise<string> {
let code: string | null = null;
await expect(async () => {
const res = await page.request.get(`/api/test/otp-code?email=${encodeURIComponent(email)}&since=${sentAfter}`);
expect(res.ok()).toBe(true);
code = ((await res.json()) as { code: string }).code;
}).toPass({ timeout: 10_000 });
if (!code) throw new Error(`Test setup failed: no OTP code captured for ${email}.`);
return code;
}
export async function signIn(page: Page, email: string): Promise<void> {
await page.goto("/sign-in"); await page.goto("/sign-in");
// Turbopack dev-mode compiles each route group on first visit — on a // Turbopack dev-mode compiles each route group on first visit — on a
// cold server, a click that lands before that finishes (and before // cold server, a click that lands before that finishes (and before
@@ -33,7 +64,11 @@ export async function signIn(page: Page, email: string, password: string): Promi
// race the very first visit to an as-yet-uncompiled route. // race the very first visit to an as-yet-uncompiled route.
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await page.getByLabel("Email Address").fill(email); await page.getByLabel("Email Address").fill(email);
await page.getByLabel("Password", { exact: true }).fill(password); const requestedAt = Date.now();
await page.getByRole("button", { name: "Continue", exact: true }).click();
const code = await fetchOtpCode(page, email, requestedAt);
await page.getByLabel("Verification Code").fill(code);
await page.getByRole("button", { name: "Sign In", exact: true }).click(); await page.getByRole("button", { name: "Sign In", exact: true }).click();
// Not asserting a specific destination here — a bare sign-in (no // Not asserting a specific destination here — a bare sign-in (no
// callbackUrl, which this helper never sets) now lands on a role-aware // callbackUrl, which this helper never sets) now lands on a role-aware
+5 -5
View File
@@ -41,7 +41,7 @@ function sidebar(page: Page) {
test.describe.serial("merged role-aware navigation", () => { test.describe.serial("merged role-aware navigation", () => {
test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => { test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => {
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff"); await page.goto("/staff");
const bar = bottomBar(page); const bar = bottomBar(page);
@@ -52,7 +52,7 @@ test.describe.serial("merged role-aware navigation", () => {
test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => { test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => {
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff"); await page.goto("/staff");
await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0); await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0);
@@ -71,7 +71,7 @@ test.describe.serial("merged role-aware navigation", () => {
test.beforeAll(async ({ browser }) => { test.beforeAll(async ({ browser }) => {
page = await browser.newPage(); page = await browser.newPage();
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
}); });
test.afterAll(async () => { test.afterAll(async () => {
@@ -161,7 +161,7 @@ test.describe.serial("merged role-aware navigation", () => {
test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => { test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => {
await page.setViewportSize(DESKTOP); await page.setViewportSize(DESKTOP);
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner"); await page.goto("/owner");
const side = sidebar(page); const side = sidebar(page);
@@ -175,7 +175,7 @@ test.describe.serial("merged role-aware navigation", () => {
test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => { test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => {
await page.setViewportSize(MOBILE); await page.setViewportSize(MOBILE);
await signIn(page, SEEDED.superAdmin.email, SEEDED.superAdmin.password); await signIn(page, SEEDED.superAdmin.email);
await page.goto("/owner"); await page.goto("/owner");
const bar = bottomBar(page); const bar = bottomBar(page);
+14 -14
View File
@@ -32,7 +32,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => { test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner"); await page.goto("/owner");
// The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content // The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content
@@ -51,7 +51,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => { test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/calendar"); await page.goto("/owner/calendar");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
@@ -79,7 +79,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("3. owner edits business details and the change persists", async ({ page }) => { test("3. owner edits business details and the change persists", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/business"); await page.goto("/owner/settings/business");
await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible();
@@ -100,7 +100,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => { test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
// Marcus and Leo each have a seeded future CONFIRMED appointment (see // Marcus and Leo each have a seeded future CONFIRMED appointment (see
// prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows // prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows
@@ -158,7 +158,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => { test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/team/add"); await page.goto("/owner/team/add");
await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible();
@@ -179,7 +179,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("6. owner changes a barber's role and service eligibility", async ({ page }) => { test("6. owner changes a barber's role and service eligibility", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/team?q=Jamie"); await page.goto("/owner/team?q=Jamie");
await page.getByRole("link", { name: /Jamie Wallace/ }).click(); await page.getByRole("link", { name: /Jamie Wallace/ }).click();
@@ -212,7 +212,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => { test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/services"); await page.goto("/owner/services");
await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible();
@@ -239,7 +239,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => { test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/booking-rules"); await page.goto("/owner/settings/booking-rules");
await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible();
@@ -269,7 +269,7 @@ test.describe.serial("owner administration journeys", () => {
await expect(page.getByText("Pending Approval")).toBeVisible(); await expect(page.getByText("Pending Approval")).toBeVisible();
// Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again. // Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again.
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/booking-rules"); await page.goto("/owner/settings/booking-rules");
await page.getByRole("switch", { name: "Require approval for new bookings" }).click(); await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
await page.getByRole("button", { name: "Save", exact: true }).click(); await page.getByRole("button", { name: "Save", exact: true }).click();
@@ -277,7 +277,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("9. owner configures the loyalty programme", async ({ page }) => { test("9. owner configures the loyalty programme", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/loyalty"); await page.goto("/owner/settings/loyalty");
await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible();
@@ -297,7 +297,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => { test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner/calendar?view=agenda"); await page.goto("/owner/calendar?view=agenda");
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits // Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
@@ -322,7 +322,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => { test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => {
await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/owner"); await page.goto("/owner");
await expect(page).toHaveURL(/\/staff$/); await expect(page).toHaveURL(/\/staff$/);
@@ -331,7 +331,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => { test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner"); await page.goto("/owner");
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are // The old "Switch to Owner Admin"/"Switch to Barber Tools" links are
@@ -345,7 +345,7 @@ test.describe.serial("owner administration journeys", () => {
}); });
test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => { test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/owner"); await page.goto("/owner");
// "View Reports" was the old Dashboard footer's button label; Reports is // "View Reports" was the old Dashboard footer's button label; Reports is
// now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx). // now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx).
+13 -10
View File
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { createPool } from "mariadb"; import { createPool } from "mariadb";
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers"; import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
/** /**
@@ -38,7 +39,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("1. barber signs in and sees today's schedule", async ({ page }) => { test("1. barber signs in and sees today's schedule", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/staff"); await page.goto("/staff");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
@@ -50,7 +51,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("2. barber opens the current appointment", async ({ page }) => { test("2. barber opens the current appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/staff/current"); await page.goto("/staff/current");
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins. // Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins.
@@ -60,7 +61,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("3. barber books, checks in, and starts an appointment", async ({ page }) => { test("3. barber books, checks in, and starts an appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan"); await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click(); await page.getByRole("button", { name: /Jordan Blake/ }).click();
@@ -82,7 +83,7 @@ test.describe.serial("barber operations journeys", () => {
test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => { test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment"); if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment");
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto(chainedAppointmentUrl); await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click(); await page.getByRole("button", { name: "Complete Appointment", exact: true }).click();
@@ -104,7 +105,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("5. barber finds a customer and adds a note", async ({ page }) => { test("5. barber finds a customer and adds a note", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/staff/clients"); await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei"); await page.getByPlaceholder("Search by name, email, or phone").fill("Osei");
@@ -120,7 +121,7 @@ test.describe.serial("barber operations journeys", () => {
test("6. barber books a follow-up from a completed appointment", async ({ page }) => { test("6. barber books a follow-up from a completed appointment", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment"); if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment");
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto(chainedAppointmentUrl); await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Book Follow-Up" }).click(); await page.getByRole("button", { name: "Book Follow-Up" }).click();
@@ -143,7 +144,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("7. barber changes their working hours", async ({ page }) => { test("7. barber changes their working hours", async ({ page }) => {
await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff/settings/working-hours"); await page.goto("/staff/settings/working-hours");
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" }); const sundaySwitch = page.getByRole("switch", { name: "Sunday working" });
@@ -176,7 +177,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("8. barber adds a recurring break", async ({ page }) => { test("8. barber adds a recurring break", async ({ page }) => {
await signIn(page, SEEDED.barberSkinfadeBeard.email, "password123"); await signIn(page, SEEDED.barberSkinfadeBeard.email);
await page.goto("/staff/settings/breaks"); await page.goto("/staff/settings/breaks");
await page.getByRole("button", { name: "Add Break", exact: true }).click(); await page.getByRole("button", { name: "Add Break", exact: true }).click();
@@ -198,7 +199,7 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => { test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/staff/settings/time-off"); await page.goto("/staff/settings/time-off");
const now = new Date(); const now = new Date();
@@ -240,13 +241,15 @@ test.describe.serial("barber operations journeys", () => {
}); });
test("10. barber receives a new-booking notification", async ({ page }) => { test("10. barber receives a new-booking notification", async ({ page }) => {
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
// Deliberately books its own fresh appointment rather than relying on journey 3's — every // Deliberately books its own fresh appointment rather than relying on journey 3's — every
// other independent journey in this file follows that same "book a fresh appointment first" // other independent journey in this file follows that same "book a fresh appointment first"
// rule (see this file's header comment), and this one was the exception, which made it fail // rule (see this file's header comment), and this one was the exception, which made it fail
// in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past // in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past
// journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which // journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking. // queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
await signIn(page, SEEDED.owner.email, "password123"); await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan"); await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click(); await page.getByRole("button", { name: /Jordan Blake/ }).click();
+1 -1
View File
@@ -3,7 +3,7 @@ import nextTypescript from "eslint-config-next/typescript";
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [ const eslintConfig = [
{ ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**"] }, { ignores: ["templates/**", "docs/**", "generated/**", ".next/**", "node_modules/**", "manager/**"] },
...nextCoreWebVitals, ...nextCoreWebVitals,
...nextTypescript, ...nextTypescript,
{ {
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
.env
.env.*
!.env.example
.DS_Store
npm-debug.log*
+60
View File
@@ -0,0 +1,60 @@
# Copy to stacks/manager/.env (kept out of git — see the repo root's
# .gitignore) and fill in real values.
# ── Platform-operator login ─────────────────────────────────────────────
MANAGER_EMAIL="you@example.com"
# bcrypt hash (cost 12) of your chosen password — generate one with:
# node -e "console.log(require('bcryptjs').hashSync('your-password', 12))"
# (run from the manager/ directory so it resolves the bcryptjs dependency)
MANAGER_PASSWORD_HASH="replace-me"
# Signs the session cookie — generate with: openssl rand -hex 32
MANAGER_SESSION_SECRET="replace-me"
# Encrypts secrets (e.g. the SMTP password) stored in Settings > Email —
# see src/lib/global-settings.ts. Must be a 32-byte value, hex-encoded.
# Generate with: openssl rand -hex 32
# Losing/rotating this makes any previously-saved secret undecryptable —
# you'd need to re-enter it in Settings afterward.
MANAGER_SETTINGS_ENCRYPTION_KEY="replace-me"
# The URL this app is actually served at (used to decide whether the
# session cookie needs its Secure attribute) — e.g. "https://manager.example.com".
MANAGER_PUBLIC_URL="https://manager.example.com"
# ── Shared MySQL (see stacks/infra/compose.yaml) ────────────────────────
MYSQL_HOST="next-chair-mysql"
MYSQL_PORT="3306"
# Same value as stacks/infra/.env's MYSQL_ROOT_PASSWORD — needed here too
# to create each new business's database/user. Two copies of the same
# value, deliberately not derived from one another — see stacks/infra/
# compose.yaml's own note on why MYSQL_*/DATABASE_* aren't cross-wired.
MYSQL_ROOT_PASSWORD="replace-me"
# ── Docker / stacks ──────────────────────────────────────────────────────
# This repo's absolute path on the VPS, e.g. "/opt/nextchair/stacks" — NOT
# a relative path. This container shells `docker compose` out to the
# HOST's dockerd via the mounted Docker socket (see src/lib/docker.ts), so
# a compose-file path only resolves to the same file on both sides if the
# bind-mount doesn't change it — meaning this must be mounted at the
# identical path inside the container too (see stacks/manager/compose.yaml).
STACKS_DIR="/opt/nextchair/stacks"
# Every business's compose.yaml (see src/lib/compose-template.ts) uses
# this image tag — rebuild with `docker build -t nextchair-app:latest .`
# from the repo root after a code update, then use each business's
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
# up.
NEXTCHAIR_IMAGE="nextchair-app:latest"
# ── Scheduler, backups, alerts (all optional) ───────────────────────────
# The built-in scheduler (notification cron, daily Google reviews sync,
# health checks, scheduled backups) starts with the Manager. Set to 1 to
# turn it off, e.g. to keep using the host-crontab fallback
# (stacks/infra/scripts/process-cron.sh) instead — never run both.
# MANAGER_SCHEDULER_DISABLED="1"
# Scheduled-backup times and the dashboard's dates use the container's
# timezone (UTC by default). Set e.g. TZ="Europe/London" to change it.
# TZ="Europe/London"
# Largest .7z accepted by the Restore page's upload (bytes; default 4 GiB).
# BACKUP_UPLOAD_MAX_BYTES="4294967296"
# An external dead-man's-switch (healthchecks.io, Better Stack…): the
# scheduler GETs this URL every 5 minutes, so you're told if the whole
# VPS or the Manager is down — something a self-hosted check can't report.
# MANAGER_HEARTBEAT_URL="https://hc-ping.com/your-uuid"
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+57
View File
@@ -0,0 +1,57 @@
# Standalone image for the management UI — separate from the main app's
# Dockerfile (see ../Dockerfile) since this needs the Docker CLI itself
# baked in (it shells out to `docker compose`/`docker exec` against the
# host's dockerd via the mounted socket — see src/lib/docker.ts), which
# the main business app never needs.
#
# Only one instance of this ever runs (unlike the business image, which
# is built once and reused across every business — see
# src/lib/compose-template.ts's own note) so a per-deploy `build:` in
# stacks/manager/compose.yaml, rather than a separately tagged image, is
# the simpler choice here.
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# The Docker CLI + compose plugin — this container never runs its own
# Docker daemon, it only talks to the HOST's via the socket mounted in
# stacks/infra/compose.yaml's manager service (docker.sock).
RUN apk add --no-cache docker-cli docker-cli-compose
# 7-Zip for backups (`7zz a`, plain 7z — see src/lib/sevenzip.ts).
# Alpine's package is named `7zip` and installs /usr/bin/7zz (checked
# against node:22-alpine, Alpine 3.24: `apk add 7zip` → 7-Zip 26.01).
RUN apk add --no-cache 7zip
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 manager
COPY --from=builder --chown=manager:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=manager:nodejs /app/.next ./.next
COPY --from=builder --chown=manager:nodejs /app/public ./public
COPY --from=builder --chown=manager:nodejs /app/package.json ./package.json
COPY --from=builder --chown=manager:nodejs /app/next.config.ts ./next.config.ts
# Deliberately still root at runtime, not the `manager` user created
# above — this container's whole job is calling the Docker CLI against
# the host's socket, and on most VPS setups only root (or a user in the
# `docker` group, which would need to be created with a matching host
# GID) can use that socket. Worth being explicit about: root inside this
# container plus the mounted socket effectively means root on the host —
# this app must stay behind its own login (see src/lib/auth.ts) and never
# be exposed without that gate.
EXPOSE 3000
ENV PORT=3000
ENTRYPOINT ["node_modules/.bin/next", "start", "-p", "3000"]
+136
View File
@@ -0,0 +1,136 @@
# NextChair Manager
Standalone platform-operator tool for provisioning and managing NextChair
business deployments on the shared multi-business VPS setup (see
`../stacks/`). Not part of the main app — a genuinely separate Next.js
app, own `package.json`, own build, own container.
## Why it's a separate app
An earlier attempt at this exact feature was built as routes inside the
main app (`src/app/manager/...`). It was reverted after repeated,
never-fully-resolved bugs: the main app's `src/proxy.ts`, root layout,
root page, and global `SessionProvider` all statically import Prisma /
Auth.js / business branding — code a Manager (which has no business
database of its own, by definition) kept transitively pulling in, causing
crashes in "Manager mode."
This app avoids that class of bug structurally rather than by careful
avoidance: it never imports anything from the main app's `src/` at all.
A few small pieces (bcrypt convention, the signed-cookie primitive) were
copied and adapted, not imported — see `src/lib/auth.ts` and
`src/lib/session.ts`.
## What it does
- Lists provisioned businesses and their running/stopped state.
- **Add Business**: collects a name/slug/domain, then generates secrets,
creates that business's database + user on the shared MySQL server,
writes its `stacks/<slug>/compose.yaml` + `.env` + Caddy site file,
reloads Caddy, starts the container, and runs its migrations. See
`src/lib/provisioning.ts` for the exact sequence.
- Per business: start / stop / restart / update (recreate the container
against the current shared image tag — not a source rebuild, and run as
a background job with live progress and an optional "back up first") /
view recent logs.
- Does **not** duplicate the `/install` wizard — after provisioning, the
operator is linked to `https://<domain>/install` to finish that
business's own setup.
## Scheduler, jobs, backups and alerts
All of this runs inside the Manager process — no host crontab, no extra
service. State lives next to `global-settings.json`, under
`STACKS_DIR/manager/` (and `STACKS_DIR/backups/`), so it survives rebuilds.
- **Scheduler** (`src/instrumentation.ts` → `src/lib/scheduler*.ts`): one
guarded interval, a task registry with per-task intervals and
per-business overlap guards, last-run results in `scheduler-state.json`
(shown at Settings → Monitoring & scheduler). Tasks: notification cron
(`POST /api/cron/process-notifications` with each stack's `CRON_SECRET`,
every 30 s by default), daily Google reviews sync, health checks (every
3 min by default), scheduled backups, hourly housekeeping, and an
optional external heartbeat (`MANAGER_HEARTBEAT_URL`). Turn it off with
`MANAGER_SCHEDULER_DISABLED=1`.
**Fallback:** `stacks/infra/scripts/process-cron.sh` (host crontab) still
works and does the same notification/reviews calls — use it only if the
scheduler is disabled, never both.
- **Jobs** (`src/lib/jobs.ts`, `locks.ts`): Update, Backup, Restore and
Delete run as background jobs writing `manager/jobs/<id>.json` (status,
steps, log tail); the UI polls `GET /api/jobs/<id>`. Each holds a
per-business lock (`manager/locks/<slug>.json`) so they can't overlap.
A lock whose heartbeat is older than 2 minutes is stale and may be taken
over; on boot, jobs left "running" are marked failed and their locks
released.
- **Backups** (`src/lib/backup*.ts`, `restore.ts`; UI at a business's
*Backups* page and the global *Restore* page): a plain `.7z`
(`7zz`, **not password-protected**) of the database dump, media and
config, in `STACKS_DIR/backups/<slug>/`. Written as `.partial`, verified
with `7zz t`, then renamed; the newest N (default 2, per business) are
kept. Safety backups (before an update or a replacing restore) and final
backups (before a delete) are kept separately and never evict them.
Because there is no password, an archive holds the business's secrets in
the clear (its `.env`: database password, auth secret, SMTP password,
VAPID private key) — files are written owner-only (0600) and the
download route is behind the Manager login; keep any downloaded copy as
private as the server itself. Restore works onto an existing business (after a safety backup) or a server that
never had it; see `RESTORE_STEPS` in `src/lib/restore.ts`. A backup
whose last migration is newer than the app image's is refused.
- **Alerts** (`src/lib/health-watcher.ts`): per-business ok / degraded /
down. Two consecutive bad checks before the first email; emails on
change and on recovery, and a daily reminder while still bad. Set the
operator address and toggles at Settings → Monitoring & scheduler (uses
the Email settings' SMTP). Businesses stopped from the Manager, or mid
update/backup/restore, are not probed. An external check is still needed
for "the whole VPS is down" — use `MANAGER_HEARTBEAT_URL`.
## How auth works
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
`MANAGER_PASSWORD_HASH`, bcrypt cost 12) — not tied to any one business's
own Super Administrator, since this tool spans every business's separate
database and has no "primary" one of its own. A signed (HMAC, not
encrypted) cookie holds the session; see `src/lib/session.ts`.
## The Docker-socket trust boundary
This container has the host's Docker socket mounted (`docker.sock`) so it
can run `docker compose`/`docker exec` against the host's dockerd — see
`src/lib/docker.ts`. Root inside this container plus that socket
effectively means root on the host. Consequences:
- It must stay behind the login above at all times — never expose it
without that gate, and don't reuse `MANAGER_PASSWORD_HASH` anywhere
else.
- `STACKS_DIR` must be bind-mounted at the *identical absolute path* on
both host and container (see `.env.example`) — a `docker compose -f
<path>` command run from inside this container is executed by the
*host's* dockerd, so the path only means the same file on both sides if
the mount doesn't change it.
## Setup
```bash
cp manager/.env.example stacks/manager/.env # fill in real values
docker compose -f stacks/manager/compose.yaml --env-file stacks/manager/.env up -d --build
```
Requires `stacks/infra/compose.yaml` already running (shared MySQL,
Caddy, the `next-chair-edge` network). Add a Caddy site for this app the
same way as any business (see `stacks/infra/sites/.gitkeep`), pointed at
`next-chair-manager:3000`.
## Local development
```bash
cd manager
npm install
cp .env.example .env.local
npm run dev
```
Requires network access to a real MySQL server and `docker`/`docker
compose` on the machine you run it from for the provisioning/stack
actions to actually work — the dashboard's read-only parts (listing
stacks) will also just show nothing if `STACKS_DIR` doesn't point at a
real directory yet.
+16
View File
@@ -0,0 +1,16 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
/** @type {import('eslint').Linter.Config[]} */
const eslintConfig = [
{ ignores: [".next/**", "node_modules/**"] },
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
},
},
];
export default eslintConfig;
+19
View File
@@ -0,0 +1,19 @@
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Server Actions here run `docker`/`docker compose`/`mariadb` calls, not
// page rendering — nothing about this app benefits from static export or
// the standalone output trimming the main app also opts out of (see
// ../Dockerfile's own header for the same reasoning).
// This directory has its own package-lock.json alongside the main
// app's at the repo root — without this, Next infers the wrong
// workspace root from the nearest lockfile and warns about it on every
// build.
turbopack: {
root: path.join(__dirname),
},
};
export default nextConfig;
+8218
View File
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "nextchair-manager",
"version": "0.1.0",
"private": true,
"description": "Standalone platform-operator tool for provisioning and managing NextChair business deployments. Deliberately separate from the main app (own package.json, own build, own container) — an earlier in-app attempt at this repeatedly broke from Prisma/Auth.js/business-data imports leaking into a mode with no business database.",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"mariadb": "^3.5.4",
"next": "^16.4.2",
"nodemailer": "^8.0.11",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"web-push": "^3.6.7"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.3",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^26.6.5",
"@types/nodemailer": "^8.0.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"eslint": "^9.39.5",
"eslint-config-next": "^16.4.2",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vitest": "^4.1.12"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
View File
Whitespace-only changes.
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
import proxy, { config } from "../proxy";
/**
* The installation-token panel (and its regenerate action) lives under
* /dashboard/[slug] like every other Manager page — it has no auth check
* of its own, relying entirely on this proxy gating every non-/login
* route (see proxy.ts's own header, and manager/README.md's "How auth
* works"). This test exists specifically because this feature is the
* first thing in this app sensitive enough that "only authenticated
* Manager users can view it" is a named requirement, not just incidental.
*/
describe("proxy — Manager auth gate", () => {
const originalSecret = process.env.MANAGER_SESSION_SECRET;
beforeEach(() => {
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
});
afterEach(() => {
if (originalSecret === undefined) delete process.env.MANAGER_SESSION_SECRET;
else process.env.MANAGER_SESSION_SECRET = originalSecret;
});
it("redirects to /login when a business dashboard page is requested without a session cookie", () => {
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"));
const response = proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost/login");
});
it("redirects to /login for an invalid/tampered session cookie", () => {
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), {
headers: { cookie: `${SESSION_COOKIE_NAME}=not-a-real-token` },
});
const response = proxy(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost/login");
});
it("passes through to the business dashboard page (where the installation token lives) with a valid session cookie", () => {
const token = createSessionToken("operator@example.com");
const request = new NextRequest(new URL("http://localhost/dashboard/acme-barbers"), {
headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` },
});
const response = proxy(request);
// NextResponse.next() carries no redirect / error status.
expect(response.status).toBe(200);
});
it("never gates /login itself (or a signed-out operator could never reach the sign-in form)", () => {
const request = new NextRequest(new URL("http://localhost/login"));
const response = proxy(request);
expect(response.status).toBe(200);
});
});
describe("proxy — API routes", () => {
const originalSecret = process.env.MANAGER_SESSION_SECRET;
beforeEach(() => {
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
});
afterEach(() => {
if (originalSecret === undefined) delete process.env.MANAGER_SESSION_SECRET;
else process.env.MANAGER_SESSION_SECRET = originalSecret;
});
it.each(["/api/jobs/abc123-deadbeef", "/api/backups/acme/acme__20261017T030000Z__regular.7z"])("gates %s behind the login", (pathname) => {
const response = proxy(new NextRequest(new URL(`http://localhost${pathname}`)));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost/login");
});
it("lets a signed-in request through to a job poll", () => {
const token = createSessionToken("op@example.com");
const response = proxy(new NextRequest(new URL("http://localhost/api/jobs/abc123-deadbeef"), { headers: { cookie: `${SESSION_COOKIE_NAME}=${token}` } }));
expect(response.status).toBe(200);
});
it("excludes ONLY the upload route from the proxy (it would truncate large bodies; the route checks the session itself)", () => {
const matcher = new RegExp(`^${config.matcher[0]!}$`);
expect(matcher.test("/api/backups/upload")).toBe(false);
expect(matcher.test("/api/backups/acme/file.7z")).toBe(true);
expect(matcher.test("/api/jobs/x")).toBe(true);
expect(matcher.test("/dashboard")).toBe(true);
});
});
@@ -0,0 +1,114 @@
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
import { GET as downloadBackup } from "../backups/[slug]/[file]/route";
import { POST as uploadBackup } from "../backups/upload/route";
import { GET as getJob } from "../jobs/[id]/route";
describe("manager API routes", () => {
let stacksDir: string;
const saved = { stacks: process.env.STACKS_DIR, secret: process.env.MANAGER_SESSION_SECRET, max: process.env.BACKUP_UPLOAD_MAX_BYTES };
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-routes-"));
process.env.STACKS_DIR = stacksDir;
process.env.MANAGER_SESSION_SECRET = "test-session-secret-value";
delete process.env.BACKUP_UPLOAD_MAX_BYTES;
});
afterEach(async () => {
for (const [key, value] of [["STACKS_DIR", saved.stacks], ["MANAGER_SESSION_SECRET", saved.secret], ["BACKUP_UPLOAD_MAX_BYTES", saved.max]] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await rm(stacksDir, { recursive: true, force: true });
});
const cookie = () => `${SESSION_COOKIE_NAME}=${createSessionToken("op@example.com")}`;
function uploadRequest(body: Uint8Array | null, headers: Record<string, string> = {}) {
return new NextRequest("http://localhost/api/backups/upload", {
method: "POST",
headers: { "content-type": "application/octet-stream", ...headers },
body: body ? new ReadableStream({ start(c) { c.enqueue(body); c.close(); } }) : null,
// undici requires this for streaming request bodies
duplex: "half",
});
}
describe("POST /api/backups/upload", () => {
it("rejects an unauthenticated upload without reading it (the route is outside the proxy, so it must check itself)", async () => {
const response = await uploadBackup(uploadRequest(new Uint8Array([1, 2, 3])));
expect(response.status).toBe(401);
await expect(readdir(path.join(stacksDir, "backups", "_uploads"))).rejects.toThrow();
});
it("streams the body to backups/_uploads/<id>.7z and returns the id and size", async () => {
const payload = new Uint8Array(5000).fill(7);
const response = await uploadBackup(uploadRequest(payload, { cookie: cookie() }));
expect(response.status).toBe(200);
const body = (await response.json()) as { uploadId: string; sizeBytes: number };
expect(body.uploadId).toMatch(/^[a-f0-9]{16}$/);
expect(body.sizeBytes).toBe(5000);
expect((await readFile(path.join(stacksDir, "backups", "_uploads", `${body.uploadId}.7z`))).length).toBe(5000);
});
it("enforces the size cap from Content-Length before reading anything", async () => {
process.env.BACKUP_UPLOAD_MAX_BYTES = "1000";
const response = await uploadBackup(uploadRequest(new Uint8Array(10), { cookie: cookie(), "content-length": "5000" }));
expect(response.status).toBe(413);
});
it("enforces the cap while streaming too (a client can understate Content-Length) and deletes the partial file", async () => {
process.env.BACKUP_UPLOAD_MAX_BYTES = "1000";
const response = await uploadBackup(uploadRequest(new Uint8Array(5000), { cookie: cookie() }));
expect(response.status).toBe(413);
expect(await readdir(path.join(stacksDir, "backups", "_uploads"))).toEqual([]);
});
it("rejects an empty upload", async () => {
const response = await uploadBackup(uploadRequest(new Uint8Array(0), { cookie: cookie() }));
expect(response.status).toBe(400);
});
});
describe("GET /api/backups/[slug]/[file]", () => {
it("streams a stored archive as an attachment", async () => {
const name = "acme__20261017T030000Z__regular.7z";
await mkdir(path.join(stacksDir, "backups", "acme"), { recursive: true });
await writeFile(path.join(stacksDir, "backups", "acme", name), "ARCHIVE-BYTES");
const response = await downloadBackup(new Request("http://localhost/x"), { params: Promise.resolve({ slug: "acme", file: name }) });
expect(response.status).toBe(200);
expect(response.headers.get("content-disposition")).toBe(`attachment; filename="${name}"`);
expect(response.headers.get("content-length")).toBe("13");
expect(await response.text()).toBe("ARCHIVE-BYTES");
});
it.each([
["acme", "../../../etc/passwd"],
["acme", "other__20261017T030000Z__regular.7z"],
["../acme", "acme__20261017T030000Z__regular.7z"],
["acme", "acme__20261017T030000Z__regular.7z"], // well-formed but absent
])("404s for %s / %s", async (slug, file) => {
const response = await downloadBackup(new Request("http://localhost/x"), { params: Promise.resolve({ slug, file }) });
expect(response.status).toBe(404);
});
});
describe("GET /api/jobs/[id]", () => {
it("returns the job record, and 404 for unknown or malformed ids", async () => {
const id = "abc123-deadbeef";
await mkdir(path.join(stacksDir, "manager", "jobs"), { recursive: true });
await writeFile(path.join(stacksDir, "manager", "jobs", `${id}.json`), JSON.stringify({ id, status: "running", steps: [], logTail: [] }));
const ok = await getJob(new Request("http://localhost/x"), { params: Promise.resolve({ id }) });
expect(ok.status).toBe(200);
expect(((await ok.json()) as { status: string }).status).toBe("running");
expect((await getJob(new Request("http://localhost/x"), { params: Promise.resolve({ id: "nope" }) })).status).toBe(404);
expect((await getJob(new Request("http://localhost/x"), { params: Promise.resolve({ id: "../../global-settings" }) })).status).toBe(404);
});
});
});
@@ -0,0 +1,36 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { NextResponse } from "next/server";
import { backupFilePath } from "@/lib/backup-store";
export const dynamic = "force-dynamic";
/** Streams a stored archive as a download — never buffered in memory, since archives can be large. The .7z is NOT password-protected and holds the business's secrets, so this route sits behind the Manager login. */
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string; file: string }> }) {
const { slug, file } = await params;
let filePath: string;
try {
filePath = backupFilePath(slug, file);
} catch {
return NextResponse.json({ error: "Not found." }, { status: 404 });
}
let size: number;
try {
size = (await stat(filePath)).size;
} catch {
return NextResponse.json({ error: "Not found." }, { status: 404 });
}
const body = Readable.toWeb(createReadStream(filePath)) as ReadableStream<Uint8Array>;
return new Response(body, {
headers: {
"Content-Type": "application/x-7z-compressed",
"Content-Length": String(size),
"Content-Disposition": `attachment; filename="${file}"`,
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,71 @@
import { createWriteStream } from "node:fs";
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { NextResponse, type NextRequest } from "next/server";
import { uploadsDir } from "@/lib/manager-paths";
import { randomHex } from "@/lib/random";
import { SESSION_COOKIE_NAME, verifySessionToken } from "@/lib/session";
export const dynamic = "force-dynamic";
/** Hard cap on an uploaded archive. Override with BACKUP_UPLOAD_MAX_BYTES. */
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024 * 1024;
function maxBytes(): number {
const configured = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_MAX_BYTES;
}
/**
* Streamed upload of a .7z backup (raw request body, not multipart, so it
* is never buffered). This route is EXCLUDED from proxy.ts's matcher on
* purpose: with a proxy in front, Next.js buffers the request body in
* memory and silently truncates it at proxyClientMaxBodySize (10 MB by
* default), which would corrupt a large archive. So it checks the
* session cookie itself, first thing, before reading a single body byte.
*
* The size cap is enforced twice: up front from Content-Length, and while
* streaming (a client can lie about, or omit, Content-Length).
*/
export async function POST(request: NextRequest) {
const session = verifySessionToken(request.cookies.get(SESSION_COOKIE_NAME)?.value);
if (!session) return NextResponse.json({ error: "Not signed in." }, { status: 401 });
const limit = maxBytes();
const declared = Number(request.headers.get("content-length"));
if (Number.isFinite(declared) && declared > limit) {
return NextResponse.json({ error: `That file is larger than the ${Math.round(limit / 1024 / 1024)} MB upload limit.` }, { status: 413 });
}
if (!request.body) return NextResponse.json({ error: "No file received." }, { status: 400 });
const uploadId = randomHex(8);
const destination = path.join(uploadsDir(), `${uploadId}.7z`);
await mkdir(uploadsDir(), { recursive: true, mode: 0o700 });
let received = 0;
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.length;
if (received > limit) callback(new Error("LIMIT_EXCEEDED"));
else callback(null, chunk);
},
});
try {
await pipeline(Readable.fromWeb(request.body as import("node:stream/web").ReadableStream), counter, createWriteStream(destination, { mode: 0o600 }));
} catch (error) {
await rm(destination, { force: true });
if (error instanceof Error && error.message === "LIMIT_EXCEEDED") {
return NextResponse.json({ error: `That file is larger than the ${Math.round(limit / 1024 / 1024)} MB upload limit.` }, { status: 413 });
}
return NextResponse.json({ error: "The upload was interrupted." }, { status: 400 });
}
if (received === 0) {
await rm(destination, { force: true });
return NextResponse.json({ error: "The uploaded file was empty." }, { status: 400 });
}
return NextResponse.json({ uploadId, sizeBytes: received });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { readJob } from "@/lib/jobs";
export const dynamic = "force-dynamic";
/**
* Polled by the JobProgress component. Behind the Manager login like
* every route (see proxy.ts). A job record holds step names, a log tail
* and a result summary — never secrets.
*/
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const job = await readJob(id);
if (!job) return NextResponse.json({ error: "No such job." }, { status: 404 });
return NextResponse.json(job, { headers: { "Cache-Control": "no-store" } });
}
@@ -0,0 +1,133 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeLogs: vi.fn(),
composeRestart: vi.fn(),
}));
const fetchInstallationStatusMock = vi.fn();
vi.mock("@/lib/installation-status", () => ({
fetchInstallationStatus: (...args: unknown[]) => fetchInstallationStatusMock(...args),
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { regenerateInstallationTokenAction } from "../actions";
const TOKEN_HEX_PATTERN = /^[a-f0-9]{64}$/;
describe("regenerateInstallationTokenAction", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-actions-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
fetchInstallationStatusMock.mockReset();
fetchInstallationStatusMock.mockResolvedValue({ status: "not_installed", installedAt: null });
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
function formData(fields: Record<string, string>): FormData {
const fd = new FormData();
for (const [key, value] of Object.entries(fields)) fd.set(key, value);
return fd;
}
it("requires confirmation on the first call — does not touch the .env or the container yet", async () => {
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
const result = await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: false, token: null }, formData({}));
expect(result).toEqual({ error: null, needsConfirmation: true, token: null });
expect(composeUpMock).not.toHaveBeenCalled();
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain("old-token");
});
it("on confirmation, replaces the token, recreates the container, and returns the new token", async () => {
await createStack("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="old-token"\nAUTH_SECRET="unrelated-secret"\n');
const result = await regenerateInstallationTokenAction(
"acme",
{ error: null, needsConfirmation: true, token: null },
formData({ confirmRegenerate: "true" }),
);
expect(result.error).toBeNull();
expect(result.token).toMatch(TOKEN_HEX_PATTERN);
expect(result.token).not.toBe("old-token");
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain(`INSTALLATION_TOKEN="${result.token}"`);
expect(envAfter).not.toContain("old-token");
// Rewriting the token must not touch unrelated secrets already in the file.
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("invalidates the previous token — the old value no longer appears anywhere in the business's config", async () => {
await createStack("acme", 'INSTALLATION_TOKEN="old-token-to-invalidate"\n');
await regenerateInstallationTokenAction("acme", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).not.toContain("old-token-to-invalidate");
});
it("refuses to regenerate once installation is complete, even if the form is (re)submitted", async () => {
await createStack("acme", 'INSTALLATION_TOKEN="old-token"\n');
fetchInstallationStatusMock.mockResolvedValue({ status: "installed", installedAt: "2026-01-01T00:00:00.000Z" });
const result = await regenerateInstallationTokenAction(
"acme",
{ error: null, needsConfirmation: true, token: null },
formData({ confirmRegenerate: "true" }),
);
expect(result.error).toMatch(/already completed installation/i);
expect(result.token).toBeNull();
expect(composeUpMock).not.toHaveBeenCalled();
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain("old-token");
});
it("tenant isolation: regenerating one business's token never touches a sibling business's .env", async () => {
await createStack("tenant-a", 'INSTALLATION_TOKEN="token-a"\n');
await createStack("tenant-b", 'INSTALLATION_TOKEN="token-b"\n');
await regenerateInstallationTokenAction("tenant-a", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" }));
const envB = await readFile(path.join(stacksDir, "tenant-b", ".env"), "utf8");
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
expect(composeUpMock).toHaveBeenCalledTimes(1);
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "tenant-a"), { forceRecreate: true });
});
it("rejects an unknown/nonexistent slug", async () => {
await expect(
regenerateInstallationTokenAction("does-not-exist", { error: null, needsConfirmation: true, token: null }, formData({ confirmRegenerate: "true" })),
).rejects.toThrow(/no such stack/i);
});
});
+122
View File
@@ -0,0 +1,122 @@
"use server";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { upsertEnvValues } from "@/lib/env-file";
import { setIntentionallyStopped } from "@/lib/health-watcher";
import { fetchInstallationStatus } from "@/lib/installation-status";
import { NO_JOB, type JobStartState } from "@/lib/job-state";
import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
import { startUpdateJob } from "@/lib/operations";
import { randomHex } from "@/lib/random";
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
async function assertStack(slug: string): Promise<string> {
if (!isValidSlug(slug) || !(await stackExists(slug))) {
throw new Error(`No such stack: ${slug}`);
}
return stackDir(slug);
}
export async function startStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await assertNotLocked(slug);
await composeUp(dir);
await setIntentionallyStopped(slug, false);
revalidatePath(`/dashboard/${slug}`);
}
export async function stopStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await assertNotLocked(slug);
// Mark it first so the health watcher doesn't alert on a stop the operator asked for.
await setIntentionallyStopped(slug, true);
await composeDown(dir);
revalidatePath(`/dashboard/${slug}`);
}
export async function restartStackAction(slug: string): Promise<void> {
const dir = await assertStack(slug);
await assertNotLocked(slug);
await composeRestart(dir);
revalidatePath(`/dashboard/${slug}`);
}
/**
* Recreates the container against whatever the shared image tag currently
* points to (--force-recreate) — not a source rebuild. See
* manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild
* step. Runs as a background job (steps: [back up], recreate, wait for
* /api/health, done) the UI follows with <JobProgress>; the per-business
* lock stops it overlapping a backup, restore or delete.
*/
export async function updateStackAction(slug: string, backupFirst: boolean): Promise<JobStartState> {
await assertStack(slug);
try {
const { jobId } = await startUpdateJob(slug, backupFirst);
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the update." };
}
}
export async function fetchLogsAction(slug: string): Promise<string> {
const dir = await assertStack(slug);
return composeLogs(dir, 200);
}
export interface RegenerateTokenState {
error: string | null;
needsConfirmation: boolean;
/** The freshly-generated token, present only right after a successful regenerate — the panel re-reads it fresh on next load otherwise, same as every other value on this page. */
token: string | null;
}
/**
* Re-issues INSTALLATION_TOKEN for a business that hasn't completed
* installation yet — same two-step confirm shape as
* dashboard/settings/actions.ts's generateVapidKeysAction: the first
* submit (no `confirmRegenerate` field) comes back with
* `needsConfirmation: true` instead of acting, and only a second submit
* with `confirmRegenerate=true` actually rotates the token.
*
* Refuses once installation is complete (checked here too, not just
* hidden in the UI — defense in depth against a stale/re-submitted
* form). Uses the exact same `randomHex` generator provisioning.ts
* itself uses, so this is a rotation of the one existing token
* mechanism, not a second one; `upsertEnvValues` (also already used by
* updateBusiness) rewrites only the one line, leaving every other
* secret in this business's .env untouched. A container recreate is
* required afterward — env vars are only read at container start (see
* docker/entrypoint.sh), never hot-reloaded.
*/
export async function regenerateInstallationTokenAction(
slug: string,
_prevState: RegenerateTokenState,
formData: FormData,
): Promise<RegenerateTokenState> {
const dir = await assertStack(slug);
const { status } = await fetchInstallationStatus(slug);
if (status === "installed") {
return {
error: "This business has already completed installation — its installation token can no longer be regenerated.",
needsConfirmation: false,
token: null,
};
}
const confirmRegenerate = formData.get("confirmRegenerate") === "true";
if (!confirmRegenerate) {
return { error: null, needsConfirmation: true, token: null };
}
const token = randomHex(32);
await upsertEnvValues(path.join(dir, ".env"), { INSTALLATION_TOKEN: token });
await composeUp(dir, { forceRecreate: true });
revalidatePath(`/dashboard/${slug}`);
return { error: null, needsConfirmation: false, token };
}
@@ -0,0 +1,62 @@
"use server";
import { revalidatePath } from "next/cache";
import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings";
import { deleteBackup } from "@/lib/backup-store";
import { NO_JOB, type JobStartState } from "@/lib/job-state";
import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
import { startBackupJob } from "@/lib/operations";
import { isValidSlug, stackExists } from "@/lib/stacks";
async function assertBusiness(slug: string): Promise<void> {
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
}
export interface SaveBackupSettingsState {
error: string | null;
saved: boolean;
}
export async function saveBackupSettingsAction(
slug: string,
_prev: SaveBackupSettingsState,
formData: FormData,
): Promise<SaveBackupSettingsState> {
await assertBusiness(slug);
const input = {
enabled: formData.get("enabled") === "on",
frequency: (formData.get("frequency") === "weekly" ? "weekly" : "daily") as BackupFrequency,
time: String(formData.get("time") ?? "").trim(),
weekday: Number(formData.get("weekday") ?? 0),
keep: Number(formData.get("keep") ?? 2),
};
const problem = validateBackupSettingsInput(input);
if (problem) return { error: problem, saved: false };
await saveBackupSettings(slug, input);
revalidatePath(`/dashboard/${slug}/backups`);
return { error: null, saved: true };
}
export async function backupNowAction(slug: string): Promise<JobStartState> {
await assertBusiness(slug);
try {
const { jobId } = await startBackupJob(slug, "regular");
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return { ...NO_JOB, error: error.message };
return { ...NO_JOB, error: error instanceof Error ? error.message : "Couldn't start the backup." };
}
}
export async function deleteBackupAction(slug: string, fileName: string): Promise<{ error: string | null }> {
try {
await assertNotLocked(slug); // a running backup/restore might be reading or pruning files
await deleteBackup(slug, fileName);
revalidatePath(`/dashboard/${slug}/backups`);
return { error: null };
} catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't delete that backup." };
}
}
@@ -0,0 +1,237 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useActionState, useState, useTransition } from "react";
import type { BackupEntry } from "@/lib/backup-store";
import { JobProgress } from "../../job-progress";
import {
backupNowAction,
deleteBackupAction,
saveBackupSettingsAction,
type SaveBackupSettingsState,
} from "./actions";
interface SettingsView {
enabled: boolean;
frequency: "daily" | "weekly";
time: string;
weekday: number;
keep: number;
lastAttemptAt: string | null;
lastSuccessAt: string | null;
lastStatus: "ok" | "failed" | null;
lastMessage: string | null;
}
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const initialSaveState: SaveBackupSettingsState = { error: null, saved: false };
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function formatWhen(iso: string | null): string {
if (!iso) return "never";
return new Date(iso).toLocaleString();
}
const KIND_LABEL: Record<BackupEntry["kind"], string> = {
regular: "Backup",
safety: "Safety (auto, before update/restore)",
final: "Final (before delete)",
};
export function BackupsPanel({
slug,
settings,
backups,
activeJobId,
timeZone,
}: {
slug: string;
settings: SettingsView;
backups: BackupEntry[];
activeJobId: string | null;
timeZone: string;
}) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveBackupSettingsAction.bind(null, slug), initialSaveState);
const [frequency, setFrequency] = useState(settings.frequency);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [jobId, setJobId] = useState<string | null>(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
const backupNow = () => {
setError(null);
startTransition(async () => {
const result = await backupNowAction(slug);
if (result.error) {
setError(result.error);
return;
}
setJobId(result.jobId);
setJobRunning(true);
});
};
const remove = (fileName: string) => {
if (!window.confirm(`Delete ${fileName}? This cannot be undone.`)) return;
setError(null);
startTransition(async () => {
const result = await deleteBackupAction(slug, fileName);
if (result.error) setError(result.error);
else router.refresh();
});
};
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-lg font-semibold">Schedule</h2>
<form action={saveAction} className="space-y-4 rounded-md border border-line bg-surface p-5">
<label className="flex items-center gap-2 text-sm font-medium">
<input type="checkbox" name="enabled" defaultChecked={settings.enabled} />
Back up this business automatically
</label>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<div>
<label htmlFor="frequency" className="block text-sm font-medium text-muted">
How often
</label>
<select
id="frequency"
name="frequency"
value={frequency}
onChange={(e) => setFrequency(e.target.value as "daily" | "weekly")}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
{frequency === "weekly" && (
<div>
<label htmlFor="weekday" className="block text-sm font-medium text-muted">
Day
</label>
<select id="weekday" name="weekday" defaultValue={settings.weekday} className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm">
{WEEKDAYS.map((day, index) => (
<option key={day} value={index}>
{day}
</option>
))}
</select>
</div>
)}
<div>
<label htmlFor="time" className="block text-sm font-medium text-muted">
Time ({timeZone})
</label>
<input id="time" name="time" type="time" required defaultValue={settings.time} className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" />
</div>
<div>
<label htmlFor="keep" className="block text-sm font-medium text-muted">
Backups to keep
</label>
<input
id="keep"
name="keep"
type="number"
min={1}
max={14}
required
defaultValue={settings.keep}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
<button type="submit" disabled={isSaving} className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{isSaving ? "Saving…" : "Save schedule"}
</button>
<p className="text-xs text-muted">
Last backup run: {formatWhen(settings.lastAttemptAt)}
{settings.lastStatus === "ok" && " — succeeded"}
{settings.lastStatus === "failed" && <span className="text-danger"> — failed: {settings.lastMessage}</span>}
</p>
</form>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Backups</h2>
<button
type="button"
onClick={backupNow}
disabled={isPending || jobRunning}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
Back up now
</button>
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
{backups.length === 0 ? (
<p className="rounded-md border border-line bg-surface p-4 text-sm text-muted">No backups yet.</p>
) : (
<ul className="divide-y divide-line rounded-md border border-line bg-surface">
{backups.map((backup) => (
<li key={backup.fileName} className="flex flex-wrap items-center gap-3 px-4 py-3 text-sm">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs">{backup.fileName}</p>
<p className="text-xs text-muted">
{new Date(backup.createdAt).toLocaleString()} · {formatBytes(backup.sizeBytes)} · {KIND_LABEL[backup.kind]}
</p>
</div>
<a
href={`/api/backups/${slug}/${backup.fileName}`}
className="rounded-md border border-line px-3 py-1 text-xs font-medium"
download
>
Download
</a>
<Link
href={`/dashboard/restore?slug=${encodeURIComponent(slug)}&file=${encodeURIComponent(backup.fileName)}`}
className="rounded-md border border-line px-3 py-1 text-xs font-medium"
>
Restore…
</Link>
<button
type="button"
disabled={isPending || jobRunning}
onClick={() => remove(backup.fileName)}
className="rounded-md border border-danger/30 px-3 py-1 text-xs font-medium text-danger disabled:opacity-60"
>
Delete
</button>
</li>
))}
</ul>
)}
</section>
<section className="space-y-2 text-sm text-muted">
<h2 className="text-lg font-semibold text-ink">Handle with care</h2>
<p>
Backups are not password-protected. Each one contains this business&apos;s secrets (its <span className="font-mono">.env</span>: database
password, sign-in secret, email password, push keys) as well as its data. They are stored owner-only on this server; if you download one,
keep it somewhere as private as the server itself.
</p>
</section>
</div>
);
}
@@ -0,0 +1,61 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { readBackupSettings } from "@/lib/backup-settings";
import { listBackups } from "@/lib/backup-store";
import { getActiveLock } from "@/lib/locks";
import { listBusinessStacks } from "@/lib/stacks";
import { BackupsPanel } from "./backups-panel";
export const metadata: Metadata = { title: "Backups" };
export const dynamic = "force-dynamic";
export default async function BackupsPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
const [settings, backups, lock] = await Promise.all([
readBackupSettings(slug),
listBackups(slug),
getActiveLock(slug),
]);
return (
<div className="space-y-6">
<div>
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
← {stack.name ?? slug}
</Link>
<h1 className="mt-2 text-xl font-bold">Backups</h1>
<p className="text-sm text-muted">
Encrypted 7z archives of the database, media files and configuration, stored on this server under{" "}
<span className="font-mono">backups/{slug}/</span>. To restore one — onto this business or a fresh server — use{" "}
<Link href="/dashboard/restore" className="font-medium text-ink underline">
Restore
</Link>
.
</p>
</div>
<BackupsPanel
slug={slug}
settings={{
enabled: settings.enabled,
frequency: settings.frequency,
time: settings.time,
weekday: settings.weekday,
keep: settings.keep,
lastAttemptAt: settings.lastAttemptAt,
lastSuccessAt: settings.lastSuccessAt,
lastStatus: settings.lastStatus,
lastMessage: settings.lastMessage,
}}
backups={backups}
activeJobId={lock?.jobId ?? null}
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
/>
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import { useState, useTransition } from "react";
import { JobProgress } from "../job-progress";
import {
fetchLogsAction,
restartStackAction,
startStackAction,
stopStackAction,
updateStackAction,
} from "./actions";
export function BusinessActions({ slug, activeJobId }: { slug: string; activeJobId: string | null }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [logs, setLogs] = useState<string | null>(null);
const [updateOpen, setUpdateOpen] = useState(false);
const [backupFirst, setBackupFirst] = useState(true);
const [jobId, setJobId] = useState<string | null>(activeJobId);
const [jobRunning, setJobRunning] = useState(activeJobId !== null);
// Quick actions are refused server-side while a job holds the business; the buttons just mirror that.
const busy = isPending || jobRunning;
const run = (action: (slug: string) => Promise<void>) => {
setError(null);
startTransition(async () => {
try {
await action(slug);
} catch (e) {
setError(e instanceof Error ? e.message : "Action failed.");
}
});
};
const runUpdate = () => {
setError(null);
startTransition(async () => {
try {
const result = await updateStackAction(slug, backupFirst);
if (result.error) {
setError(result.error);
return;
}
setJobId(result.jobId);
setJobRunning(true);
setUpdateOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Couldn't start the update.");
}
});
};
const loadLogs = () => {
setError(null);
startTransition(async () => {
try {
setLogs(await fetchLogsAction(slug));
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load logs.");
}
});
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy}
onClick={() => run(startStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Start
</button>
<button
type="button"
disabled={busy}
onClick={() => run(stopStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Stop
</button>
<button
type="button"
disabled={busy}
onClick={() => run(restartStackAction)}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
Restart
</button>
<button
type="button"
disabled={busy}
onClick={() => setUpdateOpen((open) => !open)}
aria-expanded={updateOpen}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
title="Recreates the container against the current shared image tag — not a source rebuild."
>
Update
</button>
<button
type="button"
disabled={isPending}
onClick={loadLogs}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
>
View logs
</button>
</div>
{updateOpen && !jobRunning && (
<div className="space-y-3 rounded-md border border-line bg-surface p-4">
<p className="text-sm text-muted">
Recreates the container against the current shared image tag, then waits for it to answer{" "}
<span className="font-mono">/api/health</span>. This does <strong>not</strong> rebuild the image — build{" "}
<span className="font-mono">nextchair-app</span> first if the code changed.
</p>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={backupFirst} onChange={(e) => setBackupFirst(e.target.checked)} />
Back up first (recommended)
</label>
<button
type="button"
disabled={isPending}
onClick={runUpdate}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Starting…" : "Run update"}
</button>
</div>
)}
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
{logs !== null && (
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
)}
</div>
);
}
@@ -0,0 +1,39 @@
"use server";
import { BusinessLockedError } from "@/lib/locks";
import { startDeleteJob } from "@/lib/operations";
export interface DeleteBusinessState {
error: string | null;
/** Set once the delete job has started — the form then follows it with <JobProgress>. */
jobId: string | null;
}
/**
* `slug` is bound in by the page, the same way updateBusinessAction takes
* it — the operator only ever types a *confirmation* string, never the
* slug being acted on as a submittable field.
*
* Deleting is a background job holding the business lock (so it can't
* overlap an update, backup or restore). With "final backup" ticked the
* job takes one first and aborts WITHOUT deleting if that backup fails.
*/
export async function deleteBusinessAction(
slug: string,
_prevState: DeleteBusinessState,
formData: FormData,
): Promise<DeleteBusinessState> {
const confirmation = String(formData.get("confirmation") ?? "");
if (confirmation !== slug) {
return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null };
}
const finalBackup = formData.get("finalBackup") === "on";
try {
const { jobId } = await startDeleteJob(slug, finalBackup);
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return { error: error.message, jobId: null };
return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null };
}
}
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { useActionState, useState } from "react";
import { JobProgress } from "../../job-progress";
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
const initialState: DeleteBusinessState = { error: null, jobId: null };
export function DeleteBusinessForm({ slug }: { slug: string }) {
const boundAction = deleteBusinessAction.bind(null, slug);
const [state, formAction, isPending] = useActionState(boundAction, initialState);
const [confirmation, setConfirmation] = useState("");
const canDelete = confirmation === slug;
if (state.jobId) {
return (
<div className="space-y-4">
<JobProgress jobId={state.jobId} refreshOnFinish={false}>
<Link href="/dashboard" className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
Back to businesses
</Link>
</JobProgress>
</div>
);
}
return (
<form action={formAction} className="space-y-4">
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name="finalBackup" defaultChecked className="mt-0.5" />
<span>
Take a final backup first <span className="text-muted">(recommended — kept on this server after the business is gone; deleting stops if the backup fails)</span>
</span>
</label>
<div>
<label htmlFor="confirmation" className="block text-sm font-medium text-muted">
Type <span className="font-mono font-semibold text-ink">{slug}</span> to confirm
</label>
<input
id="confirmation"
name="confirmation"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={!canDelete || isPending}
className="w-full rounded-md bg-danger py-2 text-sm font-semibold text-white disabled:opacity-40"
>
{isPending ? "Starting…" : "Delete Business"}
</button>
</form>
);
}
@@ -0,0 +1,43 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { listBusinessStacks } from "@/lib/stacks";
import { DeleteBusinessForm } from "./delete-business-form";
export const dynamic = "force-dynamic";
export default async function DeleteBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
return (
<div className="max-w-lg space-y-6">
<div>
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
← Back
</Link>
<h1 className="mt-2 text-xl font-bold text-danger">Delete {stack.name ?? slug}</h1>
</div>
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
<p className="font-semibold text-danger">This permanently deletes:</p>
<ul className="list-inside list-disc space-y-1 text-muted">
<li>The running container and its media uploads volume</li>
<li>
The <span className="font-mono">{stack.databaseName ?? "business"}</span> database — every customer,
appointment, and setting in it
</li>
<li>
Its Caddy site — {stack.domain ?? "its domain"} will stop resolving to this business immediately
</li>
<li>The stack directory and its configuration on disk</li>
</ul>
<p className="font-semibold">This cannot be undone.</p>
</div>
<DeleteBusinessForm slug={slug} />
</div>
);
}
@@ -0,0 +1,32 @@
"use server";
import { redirect } from "next/navigation";
import { updateBusiness } from "@/lib/provisioning";
export interface EditBusinessState {
error: string | null;
}
/**
* `slug` is bound in by the page (see edit-business-form.tsx's
* `.bind(null, slug)`) rather than coming from form data, the same way
* BusinessActions' start/stop/restart actions take slug as a plain
* argument — it's route context, not a field the operator can edit.
*/
export async function updateBusinessAction(
slug: string,
_prevState: EditBusinessState,
formData: FormData,
): Promise<EditBusinessState> {
const name = String(formData.get("name") ?? "");
const domain = String(formData.get("domain") ?? "");
let result: { slug: string; restarted: boolean };
try {
result = await updateBusiness({ slug, name, domain });
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to save changes." };
}
redirect(`/dashboard/${result.slug}?justUpdated=1&restarted=${result.restarted ? "1" : "0"}`);
}
@@ -0,0 +1,83 @@
"use client";
import { useActionState } from "react";
import { updateBusinessAction, type EditBusinessState } from "./actions";
const initialState: EditBusinessState = { error: null };
export function EditBusinessForm({
slug,
initialName,
initialDomain,
}: {
slug: string;
initialName: string;
initialDomain: string;
}) {
const boundAction = updateBusinessAction.bind(null, slug);
const [state, formAction, isPending] = useActionState(boundAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted">
Business name
</label>
<input
id="name"
name="name"
type="text"
required
defaultValue={initialName}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-muted">
Slug
</label>
<input
id="slug"
type="text"
value={slug}
disabled
readOnly
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-sm text-muted"
/>
<p className="mt-1 text-xs text-muted">
Fixed at creation — it&rsquo;s baked into the container name, database name, and stack directory, so it
can&rsquo;t be changed here.
</p>
</div>
<div>
<label htmlFor="domain" className="block text-sm font-medium text-muted">
Domain
</label>
<input
id="domain"
name="domain"
type="text"
required
defaultValue={initialDomain}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
Changing this updates the Caddy site and restarts the container to pick up the new domain — leaving it
unchanged saves the name only, with no restart.
</p>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Saving…" : "Save Changes"}
</button>
</form>
);
}
@@ -0,0 +1,61 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { listBusinessStacks, stackDir } from "@/lib/stacks";
import { EditBusinessForm } from "./edit-business-form";
export const dynamic = "force-dynamic";
export default async function EditBusinessPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
// Same image every business's compose.yaml resolves NEXTCHAIR_IMAGE
// to at `docker compose up` time — see compose-template.ts's
// generateComposeYaml.
const image = process.env.NEXTCHAIR_IMAGE ?? "nextchair-app:latest";
return (
<div className="max-w-lg space-y-6">
<div>
<Link href={`/dashboard/${slug}`} className="text-sm text-muted hover:text-ink">
← Back
</Link>
<h1 className="mt-2 text-xl font-bold">Edit {stack.name ?? slug}</h1>
</div>
<EditBusinessForm slug={slug} initialName={stack.name ?? ""} initialDomain={stack.domain ?? ""} />
<div className="space-y-3 rounded-md border border-line bg-surface p-4 text-sm">
<h2 className="text-xs font-bold tracking-wide text-muted uppercase">Read-only info</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<dt className="text-muted">Container status</dt>
<dd className="font-medium">{stack.status}</dd>
<dt className="text-muted">Database name</dt>
<dd className="font-mono">{stack.databaseName ?? "unknown"}</dd>
<dt className="text-muted">Stack directory</dt>
<dd className="font-mono break-all">{stackDir(slug)}</dd>
<dt className="text-muted">Image</dt>
<dd className="font-mono">{image}</dd>
<dt className="text-muted">Live site</dt>
<dd>
{stack.domain ? (
<a
href={`https://${stack.domain}`}
target="_blank"
rel="noreferrer"
className="font-medium text-accent underline"
>
https://{stack.domain}
</a>
) : (
"not set"
)}
</dd>
</dl>
</div>
</div>
);
}
@@ -0,0 +1,169 @@
"use client";
import { useActionState, useState } from "react";
import type { InstallationView } from "@/lib/installation-status";
import { regenerateInstallationTokenAction, type RegenerateTokenState } from "./actions";
const regenerateInitialState: RegenerateTokenState = { error: null, needsConfirmation: false, token: null };
function formatInstalledAt(installedAt: string | null): string | null {
if (!installedAt) return null;
const date = new Date(installedAt);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString();
}
function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard access can be denied by the browser — the token is
// still selectable/copyable by hand from the input next to
// this button, so this is a convenience failure, not a dead end.
}
}}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium hover:bg-stone"
>
{copied ? "Copied" : "Copy"}
</button>
);
}
/**
* Renders the per-business "Installation" section on the dashboard —
* see installation-status.ts's resolveInstallationView for how `view` is
* computed. Everything here is keyed off `view`/`slug`, both scoped to
* this one business — there is no code path here that could render one
* tenant's token under another tenant's page.
*/
export function InstallationTokenPanel({ slug, view }: { slug: string; view: InstallationView }) {
const [regenerateState, regenerateAction, isRegenerating] = useActionState(
regenerateInstallationTokenAction.bind(null, slug),
regenerateInitialState,
);
const currentToken = regenerateState.token ?? (view.kind !== "installed" ? view.token : null);
return (
<div className="space-y-4 rounded-md border border-line bg-surface p-5">
<h2 className="text-lg font-semibold">Installation</h2>
{view.kind === "installed" && (
<dl className="space-y-1 text-sm">
<div className="flex gap-2">
<dt className="font-medium text-muted">Status:</dt>
<dd className="font-semibold text-accent">Installed</dd>
</div>
{formatInstalledAt(view.installedAt) && (
<div className="flex gap-2">
<dt className="font-medium text-muted">Installed at:</dt>
<dd>{formatInstalledAt(view.installedAt)}</dd>
</div>
)}
</dl>
)}
{view.kind !== "installed" && (
<div className="space-y-4">
<dl className="space-y-1 text-sm">
<div className="flex gap-2">
<dt className="font-medium text-muted">Status:</dt>
<dd className="font-semibold">Not installed</dd>
</div>
{view.kind === "unknown" && (
<p className="text-xs text-muted">
Can&apos;t confirm this business&apos;s status right now — its container may be stopped or still
starting.
</p>
)}
</dl>
{view.installUrl ? (
<div className="text-sm">
<p className="font-medium text-muted">Installation URL:</p>
<a href={view.installUrl} target="_blank" rel="noreferrer" className="break-all font-mono text-xs text-accent underline">
{view.installUrl}
</a>
</div>
) : (
<p className="text-sm text-muted">No domain set yet — set one in Edit before installing.</p>
)}
<div>
<p className="text-sm font-medium text-muted">Installation token:</p>
{currentToken ? (
<div className="mt-1 flex gap-2">
<input
type="text"
readOnly
value={currentToken}
onFocus={(e) => e.currentTarget.select()}
className="w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs"
/>
<CopyButton value={currentToken} />
</div>
) : (
<p className="mt-1 text-sm text-muted">
No installation token found in this business&apos;s .env — generate one below.
</p>
)}
</div>
{regenerateState.needsConfirmation && (
<div className="space-y-1 rounded-md border border-amber-300 bg-amber-50 p-4">
<p className="text-sm font-semibold text-amber-900">Regenerate installation token?</p>
<p className="text-sm text-amber-900">The existing installation token will stop working.</p>
</div>
)}
<div className="flex flex-wrap items-center gap-3 pt-1">
{view.installUrl && (
<a
href={view.installUrl}
target="_blank"
rel="noreferrer"
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
>
Open installer
</a>
)}
<form action={regenerateAction}>
<input type="hidden" name="confirmRegenerate" value={regenerateState.needsConfirmation ? "true" : "false"} />
<button
type="submit"
disabled={isRegenerating}
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
>
{isRegenerating
? "Working…"
: regenerateState.needsConfirmation
? "Yes, Regenerate Token"
: currentToken
? "Regenerate token"
: "Generate token"}
</button>
</form>
</div>
{regenerateState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{regenerateState.error}
</p>
)}
{regenerateState.token && !regenerateState.error && (
<p className="text-sm text-green-700">New token generated and the container was recreated to pick it up.</p>
)}
</div>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { resolveInstallationView } from "@/lib/installation-status";
import { getActiveLock } from "@/lib/locks";
import { listBusinessStacks } from "@/lib/stacks";
import { BusinessActions } from "./business-actions";
import { InstallationTokenPanel } from "./installation-token-panel";
export const dynamic = "force-dynamic";
export default async function BusinessDetailPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ justCreated?: string; justUpdated?: string; restarted?: string }>;
}) {
const { slug } = await params;
const { justCreated, justUpdated, restarted } = await searchParams;
const stacks = await listBusinessStacks();
const stack = stacks.find((s) => s.slug === slug);
if (!stack) notFound();
const installationView = await resolveInstallationView(slug, stack.domain);
// A job already running for this business (started in another tab, or before a page reload) — followed live below.
const activeLock = await getActiveLock(slug);
return (
<div className="space-y-6">
{justCreated && (
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
Business created and started. Visit{" "}
<a href={`https://${stack.domain}/install`} target="_blank" rel="noreferrer" className="font-semibold underline">
https://{stack.domain}/install
</a>{" "}
to finish setting it up.
</div>
)}
{justUpdated && (
<div className="rounded-md border border-accent/30 bg-accent/10 px-4 py-3 text-sm text-accent">
Changes saved{restarted === "1" ? " — the container was restarted to pick up the new domain." : "."}
</div>
)}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold">{stack.name ?? stack.slug}</h1>
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<div className="flex gap-2">
<Link
href={`/dashboard/${stack.slug}/backups`}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
>
Backups
</Link>
<Link
href={`/dashboard/${stack.slug}/edit`}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
>
Edit
</Link>
<Link
href={`/dashboard/${stack.slug}/delete`}
className="rounded-md border border-danger/30 px-3 py-1.5 text-sm font-medium text-danger"
>
Delete
</Link>
</div>
</div>
<BusinessActions slug={stack.slug} activeJobId={activeLock?.jobId ?? null} />
<InstallationTokenPanel slug={stack.slug} view={installationView} />
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { JobRecord, StepStatus } from "@/lib/jobs";
const POLL_MS = 1500;
const STEP_GLYPH: Record<StepStatus, string> = {
pending: "○",
running: "◐",
done: "✓",
failed: "✕",
skipped: "–",
};
const STEP_COLOR: Record<StepStatus, string> = {
pending: "text-muted",
running: "text-accent",
done: "text-accent",
failed: "text-danger",
skipped: "text-muted",
};
/**
* Live progress for a background job: polls GET /api/jobs/<id> every
* 1.5s until the job finishes, showing each step's state and the log
* tail. Used by Update, Backup, Restore and Delete.
*/
export function JobProgress({
jobId,
onFinished,
refreshOnFinish = true,
children,
}: {
jobId: string;
/** Re-render the surrounding server page when the job ends. Off for Delete, whose page 404s once the business is gone. */
refreshOnFinish?: boolean;
/** Called once, when the job reaches succeeded/failed. */
onFinished?: (job: JobRecord) => void;
/** Rendered under the steps once the job has finished successfully (e.g. a link onward). */
children?: React.ReactNode;
}) {
const router = useRouter();
const [job, setJob] = useState<JobRecord | null>(null);
const [pollError, setPollError] = useState<string | null>(null);
const finishedRef = useRef(false);
const onFinishedRef = useRef(onFinished);
useEffect(() => {
onFinishedRef.current = onFinished;
}, [onFinished]);
useEffect(() => {
finishedRef.current = false;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function poll() {
try {
const response = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`, { cache: "no-store" });
if (response.status === 404) {
if (!cancelled) setPollError("This job's record is gone (it may have been cleaned up).");
return;
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const next = (await response.json()) as JobRecord;
if (cancelled) return;
setJob(next);
setPollError(null);
if (next.status !== "running") {
if (!finishedRef.current) {
finishedRef.current = true;
onFinishedRef.current?.(next);
if (refreshOnFinish) router.refresh();
}
return;
}
} catch (error) {
// A restart of the Manager itself drops polls for a moment — keep trying.
if (!cancelled) setPollError(error instanceof Error ? error.message : "Couldn't reach the Manager.");
}
if (!cancelled) timer = setTimeout(poll, POLL_MS);
}
void poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [jobId, router, refreshOnFinish]);
if (!job) {
return <p className="text-sm text-muted">{pollError ?? "Starting…"}</p>;
}
return (
<div className="space-y-3 rounded-md border border-line bg-surface p-4" aria-live="polite">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">{job.title}</h3>
<span
className={`rounded-full px-3 py-1 text-xs font-semibold ${
job.status === "running"
? "bg-stone text-muted"
: job.status === "succeeded"
? "bg-green-100 text-green-800"
: "bg-danger/10 text-danger"
}`}
>
{job.status === "running" ? "Running…" : job.status === "succeeded" ? "Done" : "Failed"}
</span>
</div>
<ol className="space-y-1.5">
{job.steps.map((step) => (
<li key={step.id} className="flex items-start gap-2 text-sm">
<span className={`w-4 shrink-0 text-center font-mono ${STEP_COLOR[step.status]}`} aria-hidden="true">
{STEP_GLYPH[step.status]}
</span>
<span className={step.status === "pending" || step.status === "skipped" ? "text-muted" : undefined}>
{step.label}
<span className="sr-only"> — {step.status}</span>
{step.detail && <span className="ml-2 text-xs text-muted">{step.detail}</span>}
</span>
</li>
))}
</ol>
{job.error && (
<p role="alert" className="text-sm font-medium text-danger">
{job.error}
</p>
)}
{job.status === "succeeded" && job.result && (
<p className="text-xs text-muted">
{Object.entries(job.result)
.filter(([, value]) => value !== null && value !== "")
.map(([key, value]) => `${key}: ${String(value)}`)
.join(" · ")}
</p>
)}
{pollError && job.status === "running" && <p className="text-xs text-muted">Reconnecting… ({pollError})</p>}
{job.logTail.length > 0 && (
<details>
<summary className="cursor-pointer text-xs font-medium text-muted">Log</summary>
<pre className="mt-2 max-h-56 overflow-auto rounded-md bg-ink p-3 text-xs text-white">{job.logTail.join("\n")}</pre>
</details>
)}
{job.status === "succeeded" && children}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Link from "next/link";
import { logoutAction } from "./logout-action";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen">
<header className="flex items-center justify-between border-b border-line bg-surface px-6 py-4">
<Link href="/dashboard" className="text-sm font-bold">
NextChair Manager
</Link>
<div className="flex items-center gap-4 text-sm">
<Link href="/dashboard/restore" className="text-muted hover:text-ink">
Restore
</Link>
<Link href="/dashboard/settings" className="text-muted hover:text-ink">
Settings
</Link>
<form action={logoutAction}>
<button type="submit" className="text-muted hover:text-ink">
Sign out
</button>
</form>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-8">{children}</main>
</div>
);
}
@@ -0,0 +1,11 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { SESSION_COOKIE_NAME } from "@/lib/session";
export async function logoutAction(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE_NAME);
redirect("/login");
}
+23
View File
@@ -0,0 +1,23 @@
"use server";
import { redirect } from "next/navigation";
import { provisionBusiness } from "@/lib/provisioning";
export interface AddBusinessState {
error: string | null;
}
export async function addBusinessAction(_prevState: AddBusinessState, formData: FormData): Promise<AddBusinessState> {
const name = String(formData.get("name") ?? "");
const slug = String(formData.get("slug") ?? "");
const domain = String(formData.get("domain") ?? "");
let result: { slug: string };
try {
result = await provisionBusiness({ name, slug, domain });
} catch (error) {
return { error: error instanceof Error ? error.message : "Failed to provision this business." };
}
redirect(`/dashboard/${result.slug}?justCreated=1`);
}
@@ -0,0 +1,82 @@
"use client";
import { useActionState, useState } from "react";
import { suggestSlug } from "@/lib/slug";
import { addBusinessAction, type AddBusinessState } from "./actions";
const initialState: AddBusinessState = { error: null };
export function AddBusinessForm() {
const [state, formAction, isPending] = useActionState(addBusinessAction, initialState);
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted">
Business name
</label>
<input
id="name"
name="name"
type="text"
required
onChange={(e) => {
if (!slugTouched) setSlug(suggestSlug(e.target.value));
}}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="slug" className="block text-sm font-medium text-muted">
Slug
</label>
<input
id="slug"
name="slug"
type="text"
required
value={slug}
onChange={(e) => {
setSlugTouched(true);
setSlug(e.target.value);
}}
className="mt-1 w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
<p className="mt-1 text-xs text-muted">
Used for the container name, database name, and stack directory — lowercase letters, numbers, and hyphens
only.
</p>
</div>
<div>
<label htmlFor="domain" className="block text-sm font-medium text-muted">
Domain
</label>
<input
id="domain"
name="domain"
type="text"
required
placeholder="business.example.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
Must already point at this server&rsquo;s IP before Caddy can issue a certificate for it.
</p>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Provisioning…" : "Create Business"}
</button>
</form>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { AddBusinessForm } from "./add-business-form";
export default function NewBusinessPage() {
return (
<div className="max-w-lg space-y-6">
<div>
<h1 className="text-xl font-bold">Add Business</h1>
<p className="text-sm text-muted">
Creates the database, generates secrets, writes the compose stack and Caddy site, and starts the
container. Takes a few seconds — the button stays disabled until it&rsquo;s done.
</p>
</div>
<AddBusinessForm />
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import { fetchInstallationStatus } from "@/lib/installation-status";
import { listBusinessStacks } from "@/lib/stacks";
export const dynamic = "force-dynamic";
function StatusPill({ status }: { status: "running" | "stopped" | "unknown" }) {
const styles = {
running: "bg-accent/10 text-accent",
stopped: "bg-danger/10 text-danger",
unknown: "bg-line text-muted",
} as const;
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${styles[status]}`}>{status}</span>;
}
/**
* Only ever the two labels from the task's own example — the nuance
* between "confirmed not installed" and "container unreachable, can't
* confirm" (see installation-status.ts's three-state model) only matters
* on the business detail page where the token itself lives. Never shows
* the token — see resolveInstallationView, not used here.
*/
function InstallPill({ installed }: { installed: boolean }) {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${installed ? "bg-accent/10 text-accent" : "bg-amber-100 text-amber-800"}`}
>
{installed ? "Installed" : "Setup required"}
</span>
);
}
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ justDeleted?: string; warnings?: string }>;
}) {
const { justDeleted, warnings } = await searchParams;
const stacks = await listBusinessStacks();
// Each check is independently timeout-bounded (see
// installation-status.ts) so one stopped/dead business's container
// can't stall this whole page — resolved in parallel rather than one
// at a time.
const installStatuses = await Promise.all(stacks.map((stack) => fetchInstallationStatus(stack.slug)));
return (
<div className="space-y-6">
{justDeleted && (
<div className="rounded-md border border-line bg-surface px-4 py-3 text-sm">
<p className="font-medium">
<span className="font-mono">{justDeleted}</span> was deleted.
</p>
{warnings && <p className="mt-1 text-xs text-danger">Some cleanup steps need a manual look: {warnings}</p>}
</div>
)}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">Businesses</h1>
<Link
href="/dashboard/new"
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white"
>
Add Business
</Link>
</div>
{stacks.length === 0 ? (
<p className="text-sm text-muted">No businesses provisioned yet.</p>
) : (
<ul className="divide-y divide-line rounded-lg border border-line bg-surface">
{stacks.map((stack, index) => (
<li key={stack.slug}>
<Link
href={`/dashboard/${stack.slug}`}
className="flex items-center justify-between px-4 py-3 hover:bg-stone"
>
<div>
<p className="text-sm font-semibold">{stack.name ?? stack.slug}</p>
<p className="text-xs text-muted">{stack.domain ?? "no domain set"}</p>
</div>
<div className="flex items-center gap-2">
<InstallPill installed={installStatuses[index]?.status === "installed"} />
<StatusPill status={stack.status} />
</div>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,94 @@
"use server";
import { BusinessLockedError } from "@/lib/locks";
import { startRestoreJob } from "@/lib/operations";
import { planRestore, readArchiveManifest, resolveArchivePath, type RestoreSource } from "@/lib/restore";
/** "backup:<slug>:<fileName>" for a stored archive, "upload:<id>" for one just uploaded. Parsed strictly; resolveArchivePath re-validates both forms. */
function parseSourceKey(key: string): RestoreSource {
if (key.startsWith("upload:")) return { kind: "upload", uploadId: key.slice("upload:".length) };
const match = /^backup:([^:]+):([^:]+)$/.exec(key);
if (!match) throw new Error("Choose a backup to restore.");
return { kind: "backup", slug: match[1]!, fileName: match[2]! };
}
export interface RestorePreview {
slug: string;
name: string | null;
domain: string;
createdAt: string;
kind: string;
lastMigration: string | null;
slugExists: boolean;
domainConflict: string | null;
schemaProblem: string | null;
}
export interface InspectRestoreState {
error: string | null;
preview: RestorePreview | null;
}
/** Reads only the manifest (fast) and reports what restoring would do — nothing is changed. */
export async function inspectRestoreAction(sourceKey: string): Promise<InspectRestoreState> {
try {
const source = parseSourceKey(sourceKey);
const manifest = await readArchiveManifest(resolveArchivePath(source));
const plan = await planRestore(manifest);
return {
error: null,
preview: {
slug: manifest.slug,
name: manifest.name,
domain: manifest.domain,
createdAt: manifest.createdAt,
kind: manifest.kind,
lastMigration: manifest.lastMigration,
slugExists: plan.slugExists,
domainConflict: plan.domainConflict,
schemaProblem: plan.schema.ok ? null : plan.schema.reason,
},
};
} catch (error) {
// 7zz fails with a generic message on a damaged or foreign file; say so plainly instead of passing its output through.
const message = error instanceof Error ? error.message : "";
const looksUnreadable = /wrong password|can not open|data error|headers error|exited with code 2/i.test(message);
return {
error: looksUnreadable
? "Couldn't open that file — it isn't a valid NextChair backup (backups made by an older version with a password can't be opened here)."
: message || "Couldn't read that backup.",
preview: null,
};
}
}
export interface StartRestoreState {
error: string | null;
jobId: string | null;
}
export async function startRestoreAction(
sourceKey: string,
replace: boolean,
confirmSlug: string,
): Promise<StartRestoreState> {
const fail = (error: string): StartRestoreState => ({ error, jobId: null });
try {
const source = parseSourceKey(sourceKey);
const manifest = await readArchiveManifest(resolveArchivePath(source));
// Re-checked here, not trusted from the preview the browser holds.
const plan = await planRestore(manifest);
if (plan.domainConflict) return fail(plan.domainConflict);
if (!plan.schema.ok) return fail(plan.schema.reason);
if (plan.slugExists && (!replace || confirmSlug !== manifest.slug)) {
return fail(`"${manifest.slug}" already exists. Tick "replace" and type its slug to confirm overwriting it.`);
}
const { jobId } = await startRestoreJob({ source, replace });
return { error: null, jobId };
} catch (error) {
if (error instanceof BusinessLockedError) return fail(error.message);
return fail(error instanceof Error ? error.message : "Couldn't start the restore.");
}
}
@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import Link from "next/link";
import { listAllBackups } from "@/lib/backup-store";
import { RestoreWizard } from "./restore-wizard";
export const metadata: Metadata = { title: "Restore" };
export const dynamic = "force-dynamic";
export default async function RestorePage({ searchParams }: { searchParams: Promise<{ slug?: string; file?: string }> }) {
const { slug, file } = await searchParams;
const backups = await listAllBackups();
const preselected = slug && file ? `backup:${slug}:${file}` : null;
const configuredMax = Number(process.env.BACKUP_UPLOAD_MAX_BYTES);
const maxUploadBytes = configuredMax > 0 ? configuredMax : 4 * 1024 * 1024 * 1024;
return (
<div className="max-w-2xl space-y-6">
<div>
<Link href="/dashboard" className="text-sm text-muted hover:text-ink">
← Businesses
</Link>
<h1 className="mt-2 text-xl font-bold">Restore a business</h1>
<p className="text-sm text-muted">
Restores a backup onto the business it came from, or onto a fresh server where it never existed. Pick a stored
backup or upload a <span className="font-mono">.7z</span>, check what it contains, then confirm.
</p>
</div>
<RestoreWizard
backups={backups.map((b) => ({
key: `backup:${b.slug}:${b.fileName}`,
slug: b.slug,
fileName: b.fileName,
createdAt: b.createdAt,
kind: b.kind,
sizeBytes: b.sizeBytes,
}))}
preselected={preselected}
maxUploadBytes={maxUploadBytes}
/>
</div>
);
}
@@ -0,0 +1,274 @@
"use client";
import Link from "next/link";
import { useRef, useState, useTransition } from "react";
import { JobProgress } from "../job-progress";
import { inspectRestoreAction, startRestoreAction, type RestorePreview } from "./actions";
interface BackupOption {
key: string;
slug: string;
fileName: string;
createdAt: string;
kind: string;
sizeBytes: number;
}
function formatBytes(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function RestoreWizard({
backups,
preselected,
maxUploadBytes,
}: {
backups: BackupOption[];
preselected: string | null;
maxUploadBytes: number;
}) {
const [mode, setMode] = useState<"stored" | "upload">(backups.length === 0 ? "upload" : "stored");
const [selected, setSelected] = useState(preselected ?? backups[0]?.key ?? "");
const [uploadKey, setUploadKey] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [uploadName, setUploadName] = useState<string | null>(null);
const [preview, setPreview] = useState<RestorePreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [replace, setReplace] = useState(false);
const [confirmSlug, setConfirmSlug] = useState("");
const [jobId, setJobId] = useState<string | null>(null);
const [restoredSlug, setRestoredSlug] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const xhrRef = useRef<XMLHttpRequest | null>(null);
const sourceKey = mode === "upload" ? uploadKey : selected;
const resetPreview = () => {
setPreview(null);
setError(null);
};
function upload(file: File) {
resetPreview();
setUploadKey(null);
if (file.size > maxUploadBytes) {
setError(`That file is ${formatBytes(file.size)}, over the ${formatBytes(maxUploadBytes)} upload limit. Copy it into the server's backups folder instead.`);
return;
}
setUploadName(file.name);
setUploadProgress(0);
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.open("POST", "/api/backups/upload");
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) setUploadProgress(Math.round((event.loaded / event.total) * 100));
};
xhr.onload = () => {
setUploadProgress(null);
try {
const body = JSON.parse(xhr.responseText) as { uploadId?: string; error?: string };
if (xhr.status === 200 && body.uploadId) setUploadKey(`upload:${body.uploadId}`);
else setError(body.error ?? "Upload failed.");
} catch {
setError("Upload failed.");
}
};
xhr.onerror = () => {
setUploadProgress(null);
setError("Upload failed — the connection dropped.");
};
xhr.send(file);
}
function inspect() {
if (!sourceKey) return;
resetPreview();
startTransition(async () => {
const result = await inspectRestoreAction(sourceKey);
if (result.error) setError(result.error);
setPreview(result.preview);
});
}
function start() {
if (!sourceKey || !preview) return;
setError(null);
startTransition(async () => {
const result = await startRestoreAction(sourceKey, replace, confirmSlug);
if (result.error) {
setError(result.error);
return;
}
setRestoredSlug(preview.slug);
setJobId(result.jobId);
});
}
const blocked = preview ? Boolean(preview.domainConflict || preview.schemaProblem) : false;
const replaceConfirmed = !preview?.slugExists || (replace && confirmSlug === preview.slug);
if (jobId) {
return (
<div className="space-y-4">
<JobProgress jobId={jobId}>
{restoredSlug && (
<Link href={`/dashboard/${restoredSlug}`} className="inline-block rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white">
Open {restoredSlug}
</Link>
)}
</JobProgress>
</div>
);
}
return (
<div className="space-y-6">
<fieldset className="space-y-3">
<legend className="text-sm font-semibold">1. Choose the backup</legend>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
checked={mode === "stored"}
onChange={() => {
setMode("stored");
resetPreview();
}}
disabled={backups.length === 0}
/>
Stored on this server
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={mode === "upload"}
onChange={() => {
setMode("upload");
resetPreview();
}}
/>
Upload a .7z
</label>
</div>
{mode === "stored" ? (
<select
value={selected}
onChange={(e) => {
setSelected(e.target.value);
resetPreview();
}}
className="w-full rounded-md border border-line px-3 py-2 font-mono text-xs"
aria-label="Stored backup"
>
{backups.map((b) => (
<option key={b.key} value={b.key}>
{b.slug} — {new Date(b.createdAt).toLocaleString()} — {formatBytes(b.sizeBytes)} — {b.kind}
</option>
))}
</select>
) : (
<div className="space-y-2">
<input
type="file"
accept=".7z"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) upload(file);
}}
className="block w-full text-sm"
/>
<p className="text-xs text-muted">Up to {formatBytes(maxUploadBytes)}. Streamed to the server, not held in memory.</p>
{uploadProgress !== null && (
<div className="h-2 w-full overflow-hidden rounded bg-stone" role="progressbar" aria-valuenow={uploadProgress} aria-valuemin={0} aria-valuemax={100}>
<div className="h-full bg-accent" style={{ width: `${uploadProgress}%` }} />
</div>
)}
{uploadKey && <p className="text-xs text-green-700">Uploaded {uploadName}.</p>}
</div>
)}
</fieldset>
<div className="space-y-2">
<button
type="button"
onClick={inspect}
disabled={isPending || !sourceKey}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending && !preview ? "Checking…" : "2. Check this backup"}
</button>
</div>
{error && (
<p role="alert" className="text-sm font-medium text-danger">
{error}
</p>
)}
{preview && (
<div className="space-y-4">
<h2 className="text-sm font-semibold">3. Review and confirm</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-md border border-line bg-surface p-4 text-sm">
<dt className="text-muted">Business</dt>
<dd>{preview.name ?? preview.slug}</dd>
<dt className="text-muted">Slug</dt>
<dd className="font-mono">{preview.slug}</dd>
<dt className="text-muted">Domain</dt>
<dd className="font-mono">{preview.domain}</dd>
<dt className="text-muted">Backup taken</dt>
<dd>{new Date(preview.createdAt).toLocaleString()}</dd>
<dt className="text-muted">Schema</dt>
<dd className="font-mono text-xs">{preview.lastMigration ?? "unknown"}</dd>
</dl>
{preview.domainConflict && (
<p role="alert" className="text-sm font-medium text-danger">
{preview.domainConflict}
</p>
)}
{preview.schemaProblem && (
<p role="alert" className="text-sm font-medium text-danger">
{preview.schemaProblem}
</p>
)}
{preview.slugExists ? (
<div className="space-y-2 rounded-md border border-danger/30 bg-danger/5 p-4 text-sm">
<p className="font-semibold text-danger">
&ldquo;{preview.slug}&rdquo; already exists on this server. Restoring REPLACES its database, media and configuration.
</p>
<p className="text-muted">A safety backup of the current state is taken first.</p>
<label className="flex items-center gap-2">
<input type="checkbox" checked={replace} onChange={(e) => setReplace(e.target.checked)} />
Replace the existing business
</label>
<input
type="text"
value={confirmSlug}
onChange={(e) => setConfirmSlug(e.target.value)}
placeholder={`Type ${preview.slug} to confirm`}
autoComplete="off"
spellCheck={false}
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
/>
</div>
) : (
<p className="text-sm text-muted">No business named &ldquo;{preview.slug}&rdquo; exists here — it will be created from the backup.</p>
)}
<button
type="button"
onClick={start}
disabled={isPending || blocked || !replaceConfirmed}
className="rounded-md bg-danger px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
>
{isPending ? "Starting…" : preview.slugExists ? "Replace and restore" : "Restore"}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,425 @@
"use server";
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout";
import { applyOperatorAlertEmailToAllBusinesses, type OperatorRolloutResult } from "@/lib/operator-rollout";
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
import {
generateVapidKeys,
isEmailConfigComplete,
isGoogleCalendarConfigComplete,
isPushConfigComplete,
isSuperAdminConfigComplete,
readSuperAdminSettings,
recordLastTestResult,
resolveEmailSettings,
resolveGoogleCalendarSettings,
resolvePushSettings,
saveEmailSettings,
saveGoogleCalendarSettings,
readOperatorSettings,
saveOperatorSettings,
savePushSubject,
saveSuperAdminSettings,
VapidKeysAlreadyExistError,
type EmailEncryptionMode,
type EmailSettingsInput,
type GoogleCalendarSettingsInput,
} from "@/lib/global-settings";
import { sendOperatorEmail } from "@/lib/operator-email";
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
import { sendTestSmtpEmail } from "@/lib/test-email";
function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode {
return value === "none" || value === "tls" ? value : "starttls";
}
function parseEmailSettingsInput(formData: FormData): EmailSettingsInput {
return {
host: String(formData.get("host") ?? "").trim(),
port: Number(formData.get("port") ?? 587) || 587,
username: String(formData.get("username") ?? "").trim(),
password: String(formData.get("password") ?? ""),
encryption: parseEncryption(formData.get("encryption")),
fromAddress: String(formData.get("fromAddress") ?? "").trim(),
fromName: String(formData.get("fromName") ?? "").trim(),
replyTo: String(formData.get("replyTo") ?? "").trim(),
};
}
export interface SaveEmailSettingsState {
error: string | null;
saved: boolean;
/** null = settings were saved but not rolled out (incomplete config — see isEmailConfigComplete); an array (possibly empty, if there are no businesses yet) means a rollout was attempted. */
rollout: EmailRolloutResult[] | null;
}
export async function saveEmailSettingsAction(
_prevState: SaveEmailSettingsState,
formData: FormData,
): Promise<SaveEmailSettingsState> {
const input = parseEmailSettingsInput(formData);
if (!input.host || !input.username || !input.fromAddress) {
return { error: "SMTP host, username, and from address are required.", saved: false, rollout: null };
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.fromAddress)) {
return { error: "Enter a valid from address.", saved: false, rollout: null };
}
if (input.replyTo && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.replyTo)) {
return { error: "Enter a valid reply-to address, or leave it blank.", saved: false, rollout: null };
}
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65535) {
return { error: "Enter a valid SMTP port.", saved: false, rollout: null };
}
let resolved;
try {
resolved = await saveEmailSettings(input);
} catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false, rollout: null };
}
if (!isEmailConfigComplete(resolved)) {
// Saved as a draft — no password configured yet (or none was ever
// set), so nothing is rolled out. A business left on "console" mode
// is safe; a business flipped to "brevo" with a blank password would
// hard-fail its very first send.
return { error: null, saved: true, rollout: null };
}
const rollout = await applyEmailSettingsToAllBusinesses(resolved);
return { error: null, saved: true, rollout };
}
export interface ReapplyEmailSettingsState {
error: string | null;
rollout: EmailRolloutResult[] | null;
}
/** Retries the rollout without requiring a field edit first — e.g. after a business's container was down during the last save. */
export async function reapplyEmailSettingsAction(
_prevState: ReapplyEmailSettingsState,
_formData: FormData,
): Promise<ReapplyEmailSettingsState> {
const resolved = await resolveEmailSettings();
if (!resolved || !isEmailConfigComplete(resolved)) {
return { error: "Email settings aren't fully configured yet — nothing to reapply.", rollout: null };
}
const rollout = await applyEmailSettingsToAllBusinesses(resolved);
return { error: null, rollout };
}
export interface SendTestEmailState {
error: string | null;
result: { ok: boolean; message: string } | null;
}
/**
* Tests with whatever is currently in the form — including an unsaved
* password change — falling back to the already-stored password only
* when the password field was left blank, the same "keep existing
* password" rule saveEmailSettingsAction follows. This lets an operator
* validate connectivity before committing to Save.
*/
export async function sendTestEmailAction(
_prevState: SendTestEmailState,
formData: FormData,
): Promise<SendTestEmailState> {
const recipient = String(formData.get("recipient") ?? "").trim();
if (!recipient || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipient)) {
return { error: "Enter a valid test recipient address.", result: null };
}
const input = parseEmailSettingsInput(formData);
if (!input.host || !input.username || !input.fromAddress) {
return { error: "SMTP host, username, and from address are required before sending a test.", result: null };
}
let password = (input.password ?? "").trim();
if (!password) {
const stored = await resolveEmailSettings();
password = stored?.password ?? "";
}
if (!password) {
return { error: "Enter the SMTP password (or save it first) before sending a test.", result: null };
}
const result = await sendTestSmtpEmail({
host: input.host,
port: input.port,
username: input.username,
password,
encryption: input.encryption,
fromAddress: input.fromAddress,
fromName: input.fromName,
replyTo: input.replyTo,
recipient,
});
await recordLastTestResult({ ok: result.ok, message: result.message, testedAt: new Date().toISOString() });
return { error: null, result };
}
// ── Push / VAPID ─────────────────────────────────────────────────────────
export interface GenerateVapidKeysState {
error: string | null;
needsConfirmation: boolean;
generated: boolean;
rollout: PushRolloutResult[] | null;
}
/**
* Generates a brand-new VAPID key pair. If one already exists, the first
* call (no `confirmOverwrite` field in the form) is rejected with
* `needsConfirmation: true` instead of an error — the form re-renders a
* warning + a second, explicit "Yes, generate new keys" button (which
* submits again with `confirmOverwrite=true`) rather than ever
* regenerating silently. See global-settings.ts's generateVapidKeys for
* why keys are generate-only (no hand-pasted key fields exist).
*/
export async function generateVapidKeysAction(
_prevState: GenerateVapidKeysState,
formData: FormData,
): Promise<GenerateVapidKeysState> {
const subject = String(formData.get("subject") ?? "").trim();
const confirmOverwrite = formData.get("confirmOverwrite") === "true";
if (subject && !/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) {
return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", needsConfirmation: false, generated: false, rollout: null };
}
let resolved;
try {
resolved = await generateVapidKeys({ subject, confirmOverwrite });
} catch (error) {
if (error instanceof VapidKeysAlreadyExistError) {
return { error: null, needsConfirmation: true, generated: false, rollout: null };
}
return { error: error instanceof Error ? error.message : "Couldn't generate VAPID keys.", needsConfirmation: false, generated: false, rollout: null };
}
if (!isPushConfigComplete(resolved)) {
// No subject yet — saved as a draft, nothing rolled out (mirrors
// saveEmailSettingsAction's incomplete-config behavior).
return { error: null, needsConfirmation: false, generated: true, rollout: null };
}
const rollout = await applyPushSettingsToAllBusinesses(resolved);
return { error: null, needsConfirmation: false, generated: true, rollout };
}
export interface SavePushSubjectState {
error: string | null;
saved: boolean;
rollout: PushRolloutResult[] | null;
}
export async function savePushSubjectAction(
_prevState: SavePushSubjectState,
formData: FormData,
): Promise<SavePushSubjectState> {
const subject = String(formData.get("subject") ?? "").trim();
if (!/^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/.test(subject)) {
return { error: "Enter a valid contact address, e.g. mailto:support@example.com.", saved: false, rollout: null };
}
const resolved = await savePushSubject(subject);
if (!resolved || !isPushConfigComplete(resolved)) {
// No key pair generated yet — the subject is saved for next time, but there's nothing to roll out.
return { error: null, saved: true, rollout: null };
}
const rollout = await applyPushSettingsToAllBusinesses(resolved);
return { error: null, saved: true, rollout };
}
export interface ReapplyPushSettingsState {
error: string | null;
rollout: PushRolloutResult[] | null;
}
export async function reapplyPushSettingsAction(
_prevState: ReapplyPushSettingsState,
_formData: FormData,
): Promise<ReapplyPushSettingsState> {
const resolved = await resolvePushSettings();
if (!resolved || !isPushConfigComplete(resolved)) {
return { error: "Push settings aren't fully configured yet — nothing to reapply.", rollout: null };
}
const rollout = await applyPushSettingsToAllBusinesses(resolved);
return { error: null, rollout };
}
// ── Super Administrator ─────────────────────────────────────────────────
export interface SaveSuperAdminState {
error: string | null;
saved: boolean;
rollout: SuperAdminRolloutResult[] | null;
}
/**
* Saves the global default Super Administrator and immediately rolls it
* out to every existing business — unlike email/push, there's no
* "incomplete config, save as a draft" case worth distinguishing (an
* email is either present or it isn't, no secondary field like a
* password gates completeness), so this always rolls out once the email
* itself validates. New businesses pick this up automatically at
* provisioning time (see provisioning.ts) — this action is what applies
* a change to businesses that already exist.
*/
export async function saveSuperAdminSettingsAction(
_prevState: SaveSuperAdminState,
formData: FormData,
): Promise<SaveSuperAdminState> {
const email = String(formData.get("email") ?? "").trim();
const firstName = String(formData.get("firstName") ?? "").trim();
const lastName = String(formData.get("lastName") ?? "").trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return { error: "Enter a valid email address.", saved: false, rollout: null };
}
const saved = await saveSuperAdminSettings({ email, firstName, lastName });
if (!isSuperAdminConfigComplete(saved)) {
return { error: null, saved: true, rollout: null };
}
const rollout = await applySuperAdminToAllBusinesses(saved);
return { error: null, saved: true, rollout };
}
export interface ReapplySuperAdminState {
error: string | null;
rollout: SuperAdminRolloutResult[] | null;
}
export async function reapplySuperAdminAction(
_prevState: ReapplySuperAdminState,
_formData: FormData,
): Promise<ReapplySuperAdminState> {
const saved = await readSuperAdminSettings();
if (!isSuperAdminConfigComplete(saved)) {
return { error: "No Super Administrator email is saved yet — nothing to reapply.", rollout: null };
}
const rollout = await applySuperAdminToAllBusinesses(saved);
return { error: null, rollout };
}
// ── Google Calendar ──────────────────────────────────────────────────────
function parseGoogleCalendarSettingsInput(formData: FormData): GoogleCalendarSettingsInput {
return {
clientId: String(formData.get("clientId") ?? "").trim(),
clientSecret: String(formData.get("clientSecret") ?? ""),
};
}
export interface SaveGoogleCalendarSettingsState {
error: string | null;
saved: boolean;
/** null = settings were saved but not rolled out (incomplete config — see isGoogleCalendarConfigComplete); an array (possibly empty) means a rollout was attempted. */
rollout: GoogleCalendarRolloutResult[] | null;
}
export async function saveGoogleCalendarSettingsAction(
_prevState: SaveGoogleCalendarSettingsState,
formData: FormData,
): Promise<SaveGoogleCalendarSettingsState> {
const input = parseGoogleCalendarSettingsInput(formData);
if (!input.clientId) {
return { error: "Client ID is required.", saved: false, rollout: null };
}
const resolved = await saveGoogleCalendarSettings(input);
if (!isGoogleCalendarConfigComplete(resolved)) {
// Saved as a draft — no client secret configured yet, so nothing is
// rolled out (mirrors saveEmailSettingsAction's incomplete-config
// behavior: a business left with a blank secret would hard-fail the
// moment a staff member tries to connect their calendar).
return { error: null, saved: true, rollout: null };
}
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
return { error: null, saved: true, rollout };
}
export interface ReapplyGoogleCalendarSettingsState {
error: string | null;
rollout: GoogleCalendarRolloutResult[] | null;
}
/** Retries the rollout without requiring a field edit first — e.g. after a business's container was down during the last save, or to backfill a newly-added business's CALENDAR_TOKEN_ENCRYPTION_KEY. */
export async function reapplyGoogleCalendarSettingsAction(
_prevState: ReapplyGoogleCalendarSettingsState,
_formData: FormData,
): Promise<ReapplyGoogleCalendarSettingsState> {
const resolved = await resolveGoogleCalendarSettings();
if (!resolved || !isGoogleCalendarConfigComplete(resolved)) {
return { error: "Google Calendar settings aren't fully configured yet — nothing to reapply.", rollout: null };
}
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
return { error: null, rollout };
}
// ── Monitoring & scheduler ───────────────────────────────────────────────
export interface SaveOperatorSettingsState {
error: string | null;
saved: boolean;
/** Set when the alert address changed and was pushed to the businesses (each restarts to pick it up); null when nothing needed rolling out. */
rollout?: OperatorRolloutResult[] | null;
}
export async function saveOperatorSettingsAction(
_prevState: SaveOperatorSettingsState,
formData: FormData,
): Promise<SaveOperatorSettingsState> {
const alertEmail = String(formData.get("alertEmail") ?? "").trim();
if (alertEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(alertEmail)) {
return { error: "Enter a valid alert email address, or leave it blank to turn alerts off.", saved: false };
}
const notificationsIntervalSeconds = Number(formData.get("notificationsIntervalSeconds"));
const healthIntervalMinutes = Number(formData.get("healthIntervalMinutes"));
if (!Number.isInteger(notificationsIntervalSeconds) || notificationsIntervalSeconds < 10 || notificationsIntervalSeconds > 3600) {
return { error: "Notification interval must be between 10 and 3600 seconds.", saved: false };
}
if (!Number.isInteger(healthIntervalMinutes) || healthIntervalMinutes < 1 || healthIntervalMinutes > 60) {
return { error: "Health check interval must be between 1 and 60 minutes.", saved: false };
}
let previousAlertEmail = "";
try {
previousAlertEmail = (await readOperatorSettings()).alertEmail;
await saveOperatorSettings({
alertEmail,
alertOnHealth: formData.get("alertOnHealth") === "on",
alertOnRecovery: formData.get("alertOnRecovery") === "on",
dailyReminder: formData.get("dailyReminder") === "on",
alertOnBackupFailure: formData.get("alertOnBackupFailure") === "on",
notificationsIntervalSeconds,
healthIntervalMinutes,
});
} catch (error) {
return { error: error instanceof Error ? error.message : "Couldn't save settings.", saved: false };
}
// Businesses read the alert address from their own .env, so a changed address has to be pushed out
// (and each restarted once). An unchanged one isn't touched at all.
let rollout: OperatorRolloutResult[] | null = null;
if (alertEmail !== previousAlertEmail) rollout = await applyOperatorAlertEmailToAllBusinesses(alertEmail);
return { error: null, saved: true, rollout };
}
export interface SendTestAlertState {
result: { ok: boolean; message: string } | null;
}
/** Sends a test alert to the SAVED alert address — so it also proves the Email settings work for alerts. */
export async function sendTestAlertAction(_prevState: SendTestAlertState, _formData: FormData): Promise<SendTestAlertState> {
const result = await sendOperatorEmail("Test alert", "This is a test alert from your NextChair Manager. If you received it, health and backup alerts will reach you.");
return { result };
}
@@ -0,0 +1,320 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import type { EmailEncryptionMode, EmailSettingsView } from "@/lib/global-settings";
import {
reapplyEmailSettingsAction,
saveEmailSettingsAction,
sendTestEmailAction,
type ReapplyEmailSettingsState,
type SaveEmailSettingsState,
type SendTestEmailState,
} from "./actions";
const saveInitialState: SaveEmailSettingsState = { error: null, saved: false, rollout: null };
const testInitialState: SendTestEmailState = { error: null, result: null };
const reapplyInitialState: ReapplyEmailSettingsState = { error: null, rollout: null };
function StatusBadge({ initial, saveState, testState }: { initial: EmailSettingsView; saveState: SaveEmailSettingsState; testState: SendTestEmailState }) {
// Most-recent-action-wins: a test result from this session takes
// priority over the persisted status, which itself takes priority over
// "not configured".
if (testState.result) {
return testState.result.ok ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Test successful</span>
) : (
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800">Test failed</span>
);
}
if (saveState.saved && saveState.rollout) {
const allOk = saveState.rollout.every((r) => r.ok);
return allOk ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
) : (
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">Configured (some businesses failed)</span>
);
}
if (initial.lastTest) {
return initial.lastTest.ok ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Test successful</span>
) : (
<span className="rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-800">Test failed</span>
);
}
if (initial.hasPassword && initial.host && initial.fromAddress) {
return <span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>;
}
return <span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>;
}
/**
* Every field is a controlled input backed by useState here, deliberately
* NOT `defaultValue`-based uncontrolled inputs (the pattern
* edit-business-form.tsx uses for its simpler 2-field/1-action form).
* This form has three separate actions (Save / Test / Reapply) sharing
* one set of fields — after any one of them resolves, Next.js re-renders
* this component with the same `initial` server prop it always had,
* which would silently reset every uncontrolled field back to its
* original value (losing whatever the operator was mid-typing, e.g. a
* new password, right after clicking "Send Test Email" but before
* clicking "Save"). Controlled state survives that re-render untouched.
*/
export function EmailSettingsForm({ initial }: { initial: EmailSettingsView }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveEmailSettingsAction, saveInitialState);
const [testState, testAction, isTesting] = useActionState(sendTestEmailAction, testInitialState);
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyEmailSettingsAction, reapplyInitialState);
// `initial` is a server-fetched snapshot from page load — after a
// successful save, refresh it so fields that reflect *stored* state
// (hasPassword's placeholder text, the Reapply button's disabled state)
// don't keep showing the pre-save snapshot even though the save
// genuinely succeeded (confirmed live: without this, "Reapply to all
// businesses" stayed disabled and the password hint still said "No
// password saved yet." right after a successful first save).
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
const [host, setHost] = useState(initial.host);
const [port, setPort] = useState(initial.port);
const [username, setUsername] = useState(initial.username);
const [password, setPassword] = useState("");
const [encryption, setEncryption] = useState<EmailEncryptionMode>(initial.encryption);
const [fromAddress, setFromAddress] = useState(initial.fromAddress);
const [fromName, setFromName] = useState(initial.fromName);
const [replyTo, setReplyTo] = useState(initial.replyTo);
const [recipient, setRecipient] = useState("");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Email / SMTP</h2>
<StatusBadge initial={initial} saveState={saveState} testState={testState} />
</div>
<p className="text-sm text-muted">
Shared by every business on this installation — used to send OTP verification codes and other transactional
email. Configuring it here means no business needs its own SMTP setup.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="host" className="block text-sm font-medium text-muted">
SMTP host
</label>
<input
id="host"
name="host"
type="text"
required
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="smtp-relay.brevo.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="port" className="block text-sm font-medium text-muted">
SMTP port
</label>
<input
id="port"
name="port"
type="number"
required
min={1}
max={65535}
value={port}
onChange={(e) => setPort(Number(e.target.value))}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-muted">
SMTP username
</label>
<input
id="username"
name="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted">
SMTP password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={initial.hasPassword ? "•••••••••••• (leave blank to keep)" : "Not set"}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
{initial.hasPassword ? "A password is already saved — leave this blank to keep it." : "No password saved yet."}
</p>
</div>
<div>
<label htmlFor="encryption" className="block text-sm font-medium text-muted">
Encryption
</label>
<select
id="encryption"
name="encryption"
value={encryption}
onChange={(e) => setEncryption(e.target.value as EmailEncryptionMode)}
className="mt-1 w-full rounded-md border border-line bg-white px-3 py-2 text-sm"
>
<option value="none">None</option>
<option value="starttls">STARTTLS</option>
<option value="tls">TLS/SSL</option>
</select>
</div>
<div>
<label htmlFor="fromAddress" className="block text-sm font-medium text-muted">
From email address
</label>
<input
id="fromAddress"
name="fromAddress"
type="email"
required
value={fromAddress}
onChange={(e) => setFromAddress(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="fromName" className="block text-sm font-medium text-muted">
From name
</label>
<input
id="fromName"
name="fromName"
type="text"
value={fromName}
onChange={(e) => setFromName(e.target.value)}
placeholder="NextChair"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">Only used when a business&apos;s own name can&apos;t be resolved (e.g. before install).</p>
</div>
<div>
<label htmlFor="replyTo" className="block text-sm font-medium text-muted">
Default reply-to address (optional)
</label>
<input
id="replyTo"
name="replyTo"
type="email"
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
Unlike the settings above, this isn&apos;t forced onto every business — it&apos;s only used as a
fallback for a business that hasn&apos;t set its own reply-to address in its own .env.
</p>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && (
<div className="text-sm text-green-700">
{saveState.rollout === null ? (
<p>Settings saved as a draft. Add a password to enable delivery and roll it out to businesses.</p>
) : (
<p>
Settings saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
business{saveState.rollout.length === 1 ? "" : "es"}.
{saveState.rollout.some((r) => !r.ok) && (
<>
{" "}
Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}
</>
)}
</p>
)}
</div>
)}
<div className="border-t border-line pt-4">
<label htmlFor="recipient" className="block text-sm font-medium text-muted">
Test recipient
</label>
<input
id="recipient"
name="recipient"
type="email"
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
placeholder="you@example.com"
className="mt-1 w-full max-w-sm rounded-md border border-line px-3 py-2 text-sm"
/>
{testState.error && (
<p role="alert" className="mt-2 text-sm font-medium text-danger">
{testState.error}
</p>
)}
{testState.result && (
<p className={`mt-2 text-sm font-medium ${testState.result.ok ? "text-green-700" : "text-danger"}`}>
{testState.result.message}
</p>
)}
</div>
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={testAction}
disabled={isTesting}
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
>
{isTesting ? "Sending…" : "Send Test Email"}
</button>
<button
type="submit"
formAction={reapplyAction}
disabled={isReapplying || !initial.hasPassword}
title={!initial.hasPassword ? "Save complete settings first" : undefined}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
</button>
</div>
{reapplyState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{reapplyState.error}
</p>
)}
{reapplyState.rollout && (
<p className="text-sm text-green-700">
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
{reapplyState.rollout.length === 1 ? "" : "es"}.
</p>
)}
</form>
</div>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import type { GoogleCalendarSettingsView } from "@/lib/global-settings";
import {
reapplyGoogleCalendarSettingsAction,
saveGoogleCalendarSettingsAction,
type ReapplyGoogleCalendarSettingsState,
type SaveGoogleCalendarSettingsState,
} from "./actions";
const saveInitialState: SaveGoogleCalendarSettingsState = { error: null, saved: false, rollout: null };
const reapplyInitialState: ReapplyGoogleCalendarSettingsState = { error: null, rollout: null };
function StatusBadge({ initial, saveState }: { initial: GoogleCalendarSettingsView; saveState: SaveGoogleCalendarSettingsState }) {
if (saveState.saved && saveState.rollout) {
const allOk = saveState.rollout.every((r) => r.ok);
return allOk ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
) : (
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">Configured (some businesses failed)</span>
);
}
if (initial.hasClientSecret && initial.clientId) {
return <span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>;
}
return <span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>;
}
/**
* One OAuth client shared by every business — see email-settings-form.tsx
* for why every field here is controlled state rather than
* `defaultValue`: this form also has two actions (Save / Reapply) sharing
* one set of fields, and re-render after either would otherwise reset an
* uncommitted edit.
*/
export function GoogleCalendarSettingsForm({ initial }: { initial: GoogleCalendarSettingsView }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveGoogleCalendarSettingsAction, saveInitialState);
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyGoogleCalendarSettingsAction, reapplyInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
const [clientId, setClientId] = useState(initial.clientId);
const [clientSecret, setClientSecret] = useState("");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Google Calendar</h2>
<StatusBadge initial={initial} saveState={saveState} />
</div>
<p className="text-sm text-muted">
One OAuth client shared by every business on this installation, so staff can sync their confirmed
appointments to their own Google Calendar without each business needing its own Google Cloud project. In
Google Cloud Console, add every business&apos;s own callback URL —{" "}
<code className="rounded bg-stone px-1 py-0.5 text-xs">
https://&lt;business-domain&gt;/api/staff/calendar/google/callback
</code>{" "}
— as an authorized redirect URI on this client.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="clientId" className="block text-sm font-medium text-muted">
Client ID
</label>
<input
id="clientId"
name="clientId"
type="text"
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="xxxxxxxxxxxx.apps.googleusercontent.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="clientSecret" className="block text-sm font-medium text-muted">
Client secret
</label>
<input
id="clientSecret"
name="clientSecret"
type="password"
autoComplete="new-password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={initial.hasClientSecret ? "•••••••••••• (leave blank to keep)" : "Not set"}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
{initial.hasClientSecret ? "A client secret is already saved — leave this blank to keep it." : "No client secret saved yet."}
</p>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && (
<div className="text-sm text-green-700">
{saveState.rollout === null ? (
<p>Settings saved as a draft. Add a client secret to enable it and roll it out to businesses.</p>
) : (
<p>
Settings saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
business{saveState.rollout.length === 1 ? "" : "es"}.
{saveState.rollout.some((r) => !r.ok) && (
<>
{" "}
Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}
</>
)}
</p>
)}
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={reapplyAction}
disabled={isReapplying || !initial.hasClientSecret}
title={!initial.hasClientSecret ? "Save complete settings first" : undefined}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
</button>
</div>
{reapplyState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{reapplyState.error}
</p>
)}
{reapplyState.rollout && (
<p className="text-sm text-green-700">
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
{reapplyState.rollout.length === 1 ? "" : "es"}.
</p>
)}
</form>
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect } from "react";
import type { OperatorSettings } from "@/lib/global-settings";
import type { SchedulerStatus } from "@/lib/scheduler-status";
import {
saveOperatorSettingsAction,
sendTestAlertAction,
type SaveOperatorSettingsState,
type SendTestAlertState,
} from "./actions";
const saveInitialState: SaveOperatorSettingsState = { error: null, saved: false };
const testInitialState: SendTestAlertState = { result: null };
function Toggle({ name, label, hint, defaultChecked }: { name: string; label: string; hint?: string; defaultChecked: boolean }) {
return (
<label className="flex items-start gap-2 text-sm">
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="mt-0.5" />
<span>
{label}
{hint && <span className="block text-xs text-muted">{hint}</span>}
</span>
</label>
);
}
export function OperatorSettingsForm({ initial, status }: { initial: OperatorSettings; status: SchedulerStatus }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveOperatorSettingsAction, saveInitialState);
const [testState, testAction, isTesting] = useActionState(sendTestAlertAction, testInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Monitoring &amp; scheduler</h2>
{initial.alertEmail ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Alerts on</span>
) : (
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Alerts off</span>
)}
</div>
<p className="text-sm text-muted">
The Manager calls every business&apos;s notification cron, checks its <span className="font-mono">/api/health</span> and runs scheduled
backups itself. Alerts are emailed with the SMTP settings above; a business must fail two checks in a row before you hear about it.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div>
<label htmlFor="alertEmail" className="block text-sm font-medium text-muted">
Operator alert email
</label>
<input
id="alertEmail"
name="alertEmail"
type="email"
defaultValue={initial.alertEmail}
placeholder="you@example.com — blank turns alerts off"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div className="space-y-2">
<Toggle name="alertOnHealth" label="Email when a business goes degraded or down" defaultChecked={initial.alertOnHealth} />
<Toggle name="alertOnRecovery" label="Email when it recovers" defaultChecked={initial.alertOnRecovery} />
<Toggle name="dailyReminder" label="Remind me daily while it is still bad" defaultChecked={initial.dailyReminder} />
<Toggle name="alertOnBackupFailure" label="Email when a scheduled backup fails" defaultChecked={initial.alertOnBackupFailure} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="notificationsIntervalSeconds" className="block text-sm font-medium text-muted">
Notification cron every (seconds)
</label>
<input
id="notificationsIntervalSeconds"
name="notificationsIntervalSeconds"
type="number"
min={10}
max={3600}
required
defaultValue={initial.notificationsIntervalSeconds}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="healthIntervalMinutes" className="block text-sm font-medium text-muted">
Health check every (minutes)
</label>
<input
id="healthIntervalMinutes"
name="healthIntervalMinutes"
type="number"
min={1}
max={60}
required
defaultValue={initial.healthIntervalMinutes}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && <p className="text-sm text-green-700">Saved.</p>}
{saveState.rollout && saveState.rollout.length > 0 && (
<div className="text-sm">
<p className="text-muted">The alert address was sent to your businesses so they can email you about server errors:</p>
<ul className="mt-1 space-y-0.5">
{saveState.rollout.map((r) => (
<li key={r.slug} className={r.ok ? "text-green-700" : "text-danger"}>
{r.slug}: {r.ok ? (r.unchanged ? "already set" : "updated and restarted") : `failed — ${r.error}`}
</li>
))}
</ul>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={testAction}
disabled={isTesting || !initial.alertEmail}
title={!initial.alertEmail ? "Save an alert email first" : "Sends to the saved address"}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isTesting ? "Sending…" : "Send test alert"}
</button>
</div>
{testState.result && (
<p role="status" className={`text-sm ${testState.result.ok ? "text-green-700" : "font-medium text-danger"}`}>
{testState.result.message}
</p>
)}
</form>
<div className="rounded-md border border-line bg-surface p-5">
<h3 className="text-sm font-semibold">Scheduler status</h3>
<p className="text-xs text-muted">
Last tick: {status.heartbeatAt ? new Date(status.heartbeatAt).toLocaleString() : "never (scheduler not running?)"}
</p>
{status.rows.length > 0 && (
<table className="mt-3 w-full text-left text-xs">
<thead className="text-muted">
<tr>
<th className="py-1 pr-3 font-medium">Task</th>
<th className="py-1 pr-3 font-medium">Last run</th>
<th className="py-1 font-medium">Result</th>
</tr>
</thead>
<tbody>
{status.rows.map(({ key, state }) => (
<tr key={key} className="border-t border-line">
<td className="py-1 pr-3 font-mono">{key}</td>
<td className="py-1 pr-3">{state.lastFinishedAt ? new Date(state.lastFinishedAt).toLocaleString() : "running / never"}</td>
<td className={`py-1 ${state.lastOk === false ? "text-danger" : ""}`}>{state.lastMessage ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import {
readEmailSettingsView,
readGoogleCalendarSettingsView,
readOperatorSettings,
readPushSettingsView,
readSuperAdminSettings,
} from "@/lib/global-settings";
import { readSchedulerStatus } from "@/lib/scheduler-status";
import { EmailSettingsForm } from "./email-settings-form";
import { GoogleCalendarSettingsForm } from "./google-calendar-settings-form";
import { OperatorSettingsForm } from "./operator-settings-form";
import { PushSettingsForm } from "./push-settings-form";
import { SuperAdminSettingsForm } from "./super-admin-settings-form";
export const metadata: Metadata = { title: "Settings" };
// Every other page under /dashboard already has this (see
// dashboard/page.tsx, [slug]/page.tsx, [slug]/edit, [slug]/delete) —
// this one was missing it. Without it, this page has no request-time API
// in its render path (no cookies()/headers(), just plain fs reads), so
// Next.js statically prerenders it once at `next build` time and serves
// that frozen snapshot on every request afterward. In production that
// means whatever Settings looked like at build time (typically nothing,
// since STACKS_DIR/the settings file aren't available inside the Docker
// build stage — env_file only applies at container run time) is what
// gets served forever, regardless of what's actually since been saved —
// confirmed for real: after a `docker compose up -d --build` rebuild,
// already-saved settings appeared blank in the UI even though the
// underlying global-settings.json on disk still had them.
export const dynamic = "force-dynamic";
/**
* Platform-wide settings shared by every business on this installation —
* see src/lib/global-settings.ts's own header for the storage design.
* "Email", "Push Notifications", "Super Administrator", and "Google
* Calendar" exist today; a later section (SMS, storage, backups,
* branding, locale, monitoring) is a sibling here, not a rewrite.
*/
export default async function SettingsPage() {
const [email, push, superAdmin, googleCalendar, operator, schedulerStatus] = await Promise.all([
readEmailSettingsView(),
readPushSettingsView(),
readSuperAdminSettings(),
readGoogleCalendarSettingsView(),
readOperatorSettings(),
readSchedulerStatus(),
]);
return (
<div className="space-y-8">
<div>
<h1 className="text-xl font-bold">Settings</h1>
<p className="text-sm text-muted">Platform-wide configuration shared by every business on this installation.</p>
</div>
<EmailSettingsForm initial={email} />
<PushSettingsForm initial={push} />
<SuperAdminSettingsForm initial={superAdmin} />
<GoogleCalendarSettingsForm initial={googleCalendar} />
<OperatorSettingsForm initial={operator} status={schedulerStatus} />
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import type { PushSettingsView } from "@/lib/global-settings";
import {
generateVapidKeysAction,
reapplyPushSettingsAction,
savePushSubjectAction,
type GenerateVapidKeysState,
type ReapplyPushSettingsState,
type SavePushSubjectState,
} from "./actions";
const generateInitialState: GenerateVapidKeysState = { error: null, needsConfirmation: false, generated: false, rollout: null };
const saveInitialState: SavePushSubjectState = { error: null, saved: false, rollout: null };
const reapplyInitialState: ReapplyPushSettingsState = { error: null, rollout: null };
function StatusBadge({ initial, generateState, saveState }: { initial: PushSettingsView; generateState: GenerateVapidKeysState; saveState: SavePushSubjectState }) {
const isConfigured =
(generateState.generated && generateState.rollout !== null) ||
(saveState.saved && saveState.rollout !== null) ||
(initial.hasPrivateKey && Boolean(initial.subject));
return isConfigured ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
) : (
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
);
}
/**
* No hand-pasted key fields — see global-settings.ts's generateVapidKeys
* for why (a mismatched pair would let browsers subscribe successfully
* while every server-side send silently fails). All inputs are controlled
* (useState), same reasoning as email-settings-form.tsx: this form has
* three separate actions sharing one Subject field, and an uncontrolled
* `defaultValue` input would reset after any one of them resolves.
*
* No `<Modal>` here — Manager has no shared dialog primitive (no UI kit
* at all, by design; see edit-business-form.tsx's plain-HTML convention).
* The overwrite-protection ask is met with an inline warning panel
* instead: "Generate" is rejected server-side the first time
* (`needsConfirmation: true`) rather than acting immediately, and only a
* second, explicit click on "Yes, Generate New Keys" resubmits with
* `confirmOverwrite=true`.
*/
export function PushSettingsForm({ initial }: { initial: PushSettingsView }) {
const router = useRouter();
const [generateState, generateAction, isGenerating] = useActionState(generateVapidKeysAction, generateInitialState);
const [saveState, saveAction, isSaving] = useActionState(savePushSubjectAction, saveInitialState);
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyPushSettingsAction, reapplyInitialState);
// See email-settings-form.tsx's identical effect for why: `initial` is
// a server snapshot from page load, and fields like the Public
// Key/Private Key display and the Reapply button's disabled state
// depend on it directly — without refreshing, they'd keep showing
// "Not generated yet" even immediately after a successful Generate.
useEffect(() => {
if (generateState.generated || saveState.saved) router.refresh();
}, [generateState, saveState, router]);
const [subject, setSubject] = useState(initial.subject || "mailto:");
const [confirmOverwrite, setConfirmOverwrite] = useState(false);
const hasExistingKeys = initial.hasPrivateKey || generateState.generated;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Push Notifications</h2>
<StatusBadge initial={initial} generateState={generateState} saveState={saveState} />
</div>
<p className="text-sm text-muted">
Shared by every business on this installation — lets any business send browser push notifications without
generating its own key pair. Individual businesses and users still separately control whether they use push
at all; this only controls whether the platform is technically able to.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div>
<label htmlFor="publicKey" className="block text-sm font-medium text-muted">
VAPID Public Key
</label>
<input
id="publicKey"
type="text"
readOnly
value={initial.publicKey || "Not generated yet"}
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs text-muted"
/>
<p className="mt-1 text-xs text-muted">Not a secret — browsers need this to create a push subscription.</p>
</div>
<div>
<label htmlFor="privateKeyDisplay" className="block text-sm font-medium text-muted">
VAPID Private Key
</label>
<input
id="privateKeyDisplay"
type="text"
readOnly
value={initial.hasPrivateKey ? "•••••••••••••••••••••••••••••••••••••" : "Not generated yet"}
className="mt-1 w-full rounded-md border border-line bg-stone px-3 py-2 font-mono text-xs text-muted"
/>
<p className="mt-1 text-xs text-muted">Never shown, logged, or returned to the browser once generated.</p>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-muted">
Contact / Subject Email
</label>
<input
id="subject"
name="subject"
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="mailto:support@example.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
A contact address push services may use if there&apos;s an abuse concern — must start with{" "}
<code>mailto:</code>.
</p>
</div>
{generateState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{generateState.error}
</p>
)}
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{(generateState.generated || saveState.saved) && !generateState.error && !saveState.error && (
<div className="text-sm text-green-700">
{(() => {
const rollout = generateState.rollout ?? saveState.rollout;
if (rollout === null) return <p>Saved as a draft — add a contact address and keys to enable delivery.</p>;
return (
<p>
Applied to {rollout.filter((r) => r.ok).length} of {rollout.length} business
{rollout.length === 1 ? "" : "es"}.
{rollout.some((r) => !r.ok) && (
<> Failed: {rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}</>
)}
</p>
);
})()}
</div>
)}
{generateState.needsConfirmation && (
<div className="space-y-3 rounded-md border border-amber-300 bg-amber-50 p-4">
<p className="text-sm font-semibold text-amber-900">A VAPID key pair already exists.</p>
<p className="text-sm text-amber-900">
Every browser push subscription across every business was created using the current public key.
Generating a new pair will make those subscriptions stop receiving pushes — each device will need to
re-enable push notifications afterward (the app already detects and cleans up the broken subscriptions
automatically the next time a push is attempted; nothing needs to be deleted by hand).
</p>
<label className="flex items-center gap-2 text-sm font-medium text-amber-900">
<input
type="checkbox"
name="confirmOverwrite"
value="true"
checked={confirmOverwrite}
onChange={(e) => setConfirmOverwrite(e.target.checked)}
/>
I understand this will require every existing subscription to be re-created.
</label>
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={generateAction}
disabled={isGenerating || (generateState.needsConfirmation && !confirmOverwrite)}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isGenerating
? "Generating…"
: generateState.needsConfirmation
? "Yes, Generate New Keys"
: hasExistingKeys
? "Regenerate VAPID Keys"
: "Generate VAPID Keys"}
</button>
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md border border-line px-4 py-2 text-sm font-semibold disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={reapplyAction}
disabled={isReapplying || !initial.hasPrivateKey}
title={!initial.hasPrivateKey ? "Generate keys first" : undefined}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
</button>
</div>
{reapplyState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{reapplyState.error}
</p>
)}
{reapplyState.rollout && (
<p className="text-sm text-green-700">
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
{reapplyState.rollout.length === 1 ? "" : "es"}.
</p>
)}
</form>
</div>
);
}
@@ -0,0 +1,151 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import type { SuperAdminSettings } from "@/lib/global-settings";
import {
reapplySuperAdminAction,
saveSuperAdminSettingsAction,
type ReapplySuperAdminState,
type SaveSuperAdminState,
} from "./actions";
const saveInitialState: SaveSuperAdminState = { error: null, saved: false, rollout: null };
const reapplyInitialState: ReapplySuperAdminState = { error: null, rollout: null };
function StatusBadge({ initial, saveState }: { initial: SuperAdminSettings | null; saveState: SaveSuperAdminState }) {
const configured = (saveState.saved && !!saveState.rollout) || !!initial?.email;
return configured ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
) : (
<span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
);
}
/** Controlled inputs — same reasoning as email-settings-form.tsx: two actions (Save / Reapply) share one field set, and an uncontrolled defaultValue input would reset after either resolves. */
export function SuperAdminSettingsForm({ initial }: { initial: SuperAdminSettings | null }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveSuperAdminSettingsAction, saveInitialState);
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplySuperAdminAction, reapplyInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
const [email, setEmail] = useState(initial?.email ?? "");
const [firstName, setFirstName] = useState(initial?.firstName ?? "");
const [lastName, setLastName] = useState(initial?.lastName ?? "");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Super Administrator</h2>
<StatusBadge initial={initial} saveState={saveState} />
</div>
<p className="text-sm text-muted">
A platform-level account with every Owner permission, provisioned automatically on every business — no
password, it signs in the same one-time-code way as everyone else. Never appears in any business&apos;s Team
list, the customer-facing barber picker, or staff totals, and can never receive an appointment.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="email" className="block text-sm font-medium text-muted">
Email
</label>
<input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div />
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-muted">
First name
</label>
<input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-muted">
Last name
</label>
<input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && (
<div className="text-sm text-green-700">
{saveState.rollout === null ? (
<p>Saved as a draft — enter an email to apply it to every business.</p>
) : (
<p>
Saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "}
business{saveState.rollout.length === 1 ? "" : "es"}.
{saveState.rollout.some((r) => !r.ok) && (
<> Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")}</>
)}
</p>
)}
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={reapplyAction}
disabled={isReapplying || !initial?.email}
title={!initial?.email ? "Save an email first" : undefined}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
</button>
</div>
{reapplyState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{reapplyState.error}
</p>
)}
{reapplyState.rollout && (
<p className="text-sm text-green-700">
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
{reapplyState.rollout.length === 1 ? "" : "es"}.
</p>
)}
</form>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@theme {
--color-ink: #111b15;
--color-stone: #f4f6f4;
--color-surface: #ffffff;
--color-muted: #5b665f;
--color-line: #dfe3df;
--color-accent: #1f7a4d;
--color-danger: #b3261e;
}
body {
background-color: var(--color-stone);
color: var(--color-ink);
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "NextChair Manager",
description: "Platform-operator tool for provisioning NextChair business deployments.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { verifyManagerCredentials } from "@/lib/auth";
import { createSessionToken, isSecureDeployment, SESSION_COOKIE_NAME } from "@/lib/session";
export interface LoginState {
error: string | null;
}
export async function loginAction(_prevState: LoginState, formData: FormData): Promise<LoginState> {
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
if (!email || !password) {
return { error: "Email and password are required." };
}
const valid = await verifyManagerCredentials(email, password);
if (!valid) {
return { error: "Incorrect email or password." };
}
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, createSessionToken(email), {
httpOnly: true,
secure: isSecureDeployment(),
sameSite: "lax",
path: "/",
maxAge: 7 * 24 * 60 * 60,
});
redirect("/dashboard");
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useActionState } from "react";
import { loginAction, type LoginState } from "./actions";
const initialState: LoginState = { error: null };
export function LoginForm() {
const [state, formAction, isPending] = useActionState(loginAction, initialState);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-muted">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="username"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-muted">
Password
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
{state.error && (
<p role="alert" className="text-sm font-medium text-danger">
{state.error}
</p>
)}
<button
type="submit"
disabled={isPending}
className="w-full rounded-md bg-ink py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isPending ? "Signing in…" : "Sign in"}
</button>
</form>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { LoginForm } from "./login-form";
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-line bg-surface p-8 shadow-sm">
<div>
<h1 className="text-lg font-bold">NextChair Manager</h1>
<p className="text-sm text-muted">Platform-operator sign in.</p>
</div>
<LoginForm />
</div>
</main>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Runs once when the Manager's server starts (Next.js instrumentation).
* Starts the built-in scheduler — notification cron, daily reviews sync,
* health checks, scheduled backups — as ONE interval guarded by a
* globalThis singleton (see lib/scheduler-start.ts).
*
* Node.js runtime only (the scheduler uses fs/child_process), and skipped
* when STACKS_DIR isn't set or MANAGER_SCHEDULER_DISABLED=1 (e.g. a
* throwaway `next dev` with no stacks, or to hand cron back to the host's
* stacks/infra/scripts/process-cron.sh).
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (!process.env.STACKS_DIR) return;
if (process.env.MANAGER_SCHEDULER_DISABLED === "1") {
console.log("[scheduler] disabled by MANAGER_SCHEDULER_DISABLED=1.");
return;
}
const { startScheduler } = await import("./lib/scheduler-start");
await startScheduler();
}
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { buildManifest, checkMigrationCompatibility, ManifestError, parseManifest } from "../backup-manifest";
const valid = () =>
buildManifest({
slug: "acme",
name: "Acme Barbers",
domain: "acme.example.com",
databaseName: "acme",
databaseUser: "acme",
createdAt: "2026-10-17T03:00:00.000Z",
kind: "regular",
lastMigration: "20261017180000_add_business_holidays",
appImage: "nextchair-app:latest",
hasCaddySite: true,
});
describe("parseManifest", () => {
it("round-trips a manifest it built", () => {
const parsed = parseManifest(JSON.stringify(valid()));
expect(parsed).toEqual(valid());
expect(parsed.formatVersion).toBe(1);
});
it("accepts a manifest with no recorded migration", () => {
expect(parseManifest(JSON.stringify({ ...valid(), lastMigration: null })).lastMigration).toBeNull();
});
it("rejects non-JSON and non-objects", () => {
expect(() => parseManifest("not json")).toThrow(ManifestError);
expect(() => parseManifest("[]")).toThrow(ManifestError);
expect(() => parseManifest("null")).toThrow(ManifestError);
});
it("rejects a format newer than this Manager understands", () => {
expect(() => parseManifest(JSON.stringify({ ...valid(), formatVersion: 99 }))).toThrow(/newer than this Manager/);
});
it.each([
["slug", { slug: "Not A Slug" }],
["slug", { slug: "infra" }],
["domain", { domain: 'evil".com' }],
["database name", { databaseName: "acme`; DROP DATABASE x; --" }],
["database user", { databaseUser: "root'@'%" }],
["createdAt", { createdAt: "yesterday" }],
["kind", { kind: "nightly" }],
["migration", { lastMigration: "../../etc" }],
["business name", { name: 'Acme "quoted"' }],
])("rejects an unsafe or invalid %s", (_label, override) => {
expect(() => parseManifest(JSON.stringify({ ...valid(), ...override }))).toThrow(ManifestError);
});
it("rejects a manifest missing required fields", () => {
const { domain: _domain, ...rest } = valid();
expect(() => parseManifest(JSON.stringify(rest))).toThrow(/domain/);
});
});
describe("checkMigrationCompatibility (version guard)", () => {
const image = ["20260912165903_add_staff_selected_by_customer", "20261017180000_add_business_holidays"];
it("allows a backup on the image's newest migration", () => {
expect(checkMigrationCompatibility("20261017180000_add_business_holidays", image)).toEqual({ ok: true });
});
it("allows an older backup (the entrypoint migrates it forward)", () => {
expect(checkMigrationCompatibility("20260912165903_add_staff_selected_by_customer", image)).toEqual({ ok: true });
});
it("refuses a backup whose last migration is newer than the image's", () => {
const result = checkMigrationCompatibility("20271101000000_future_thing", image);
expect(result.ok).toBe(false);
expect(!result.ok && result.reason).toMatch(/newer than the running app image/);
});
it("allows a backup with no recorded migration", () => {
expect(checkMigrationCompatibility(null, image)).toEqual({ ok: true });
});
it("refuses when the image's migration list can't be read", () => {
expect(checkMigrationCompatibility("20261017180000_add_business_holidays", []).ok).toBe(false);
});
});
@@ -0,0 +1,118 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
DEFAULT_BACKUP_SETTINGS,
isBackupDue,
lastScheduledOccurrence,
readBackupSettings,
recordBackupAttempt,
recordBackupOutcome,
saveBackupSettings,
validateBackupSettingsInput,
type BackupSettings,
} from "../backup-settings";
// All dates are built from LOCAL components, like the scheduler's own maths, so these hold in any TZ.
const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min, 0, 0);
const base = (over: Partial<BackupSettings> = {}): BackupSettings => ({ ...DEFAULT_BACKUP_SETTINGS, enabled: true, ...over });
describe("lastScheduledOccurrence", () => {
it("daily: today's time if it has passed, otherwise yesterday's", () => {
const s = { frequency: "daily" as const, time: "03:00", weekday: 0 };
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 14, 30))).toEqual(at(2026, 10, 17, 3, 0));
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 2, 59))).toEqual(at(2026, 10, 16, 3, 0));
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 3, 0))).toEqual(at(2026, 10, 17, 3, 0));
});
it("weekly: the most recent matching weekday at that time", () => {
// 2026-10-17 is a Saturday (6). Weekly on Monday (1) 04:00 → Monday 12th.
const s = { frequency: "weekly" as const, time: "04:00", weekday: 1 };
expect(lastScheduledOccurrence(s, at(2026, 10, 17, 12, 0))).toEqual(at(2026, 10, 12, 4, 0));
// On Monday before 04:00 it is still the previous Monday.
expect(lastScheduledOccurrence(s, at(2026, 10, 19, 3, 0))).toEqual(at(2026, 10, 12, 4, 0));
expect(lastScheduledOccurrence(s, at(2026, 10, 19, 4, 1))).toEqual(at(2026, 10, 19, 4, 0));
});
});
describe("isBackupDue", () => {
it("is never due when disabled", () => {
expect(isBackupDue(base({ enabled: false }), at(2026, 10, 17, 12))).toBe(false);
});
it("does not fire the moment it is enabled — it waits for the next scheduled time", () => {
const enabledAt = at(2026, 10, 17, 14, 0).toISOString();
const s = base({ time: "03:00", enabledAt });
expect(isBackupDue(s, at(2026, 10, 17, 14, 1))).toBe(false);
expect(isBackupDue(s, at(2026, 10, 18, 3, 0))).toBe(true);
});
it("is due once a scheduled time has passed since the last attempt, and not again until the next", () => {
const s = base({ time: "03:00", lastAttemptAt: at(2026, 10, 16, 3, 0).toISOString(), enabledAt: at(2026, 10, 1).toISOString() });
expect(isBackupDue(s, at(2026, 10, 17, 2, 59))).toBe(false);
expect(isBackupDue(s, at(2026, 10, 17, 3, 0))).toBe(true);
const after = { ...s, lastAttemptAt: at(2026, 10, 17, 3, 0, ).toISOString() };
expect(isBackupDue(after, at(2026, 10, 17, 23, 0))).toBe(false);
expect(isBackupDue(after, at(2026, 10, 18, 3, 0))).toBe(true);
});
it("catches up a run missed while the Manager was down", () => {
const s = base({ time: "03:00", lastAttemptAt: at(2026, 10, 10, 3, 0).toISOString(), enabledAt: at(2026, 10, 1).toISOString() });
expect(isBackupDue(s, at(2026, 10, 17, 9, 0))).toBe(true);
});
it("weekly: due only after that weekday's time", () => {
const s = base({ frequency: "weekly", weekday: 1, time: "04:00", lastAttemptAt: at(2026, 10, 12, 4, 0).toISOString(), enabledAt: at(2026, 9, 1).toISOString() });
expect(isBackupDue(s, at(2026, 10, 18, 12))).toBe(false); // Sunday
expect(isBackupDue(s, at(2026, 10, 19, 4, 0))).toBe(true); // next Monday 04:00
});
});
describe("settings store", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-backup-settings-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
it("defaults to disabled, daily, 03:00, keep 2", async () => {
expect(await readBackupSettings("acme")).toMatchObject({ enabled: false, frequency: "daily", time: "03:00", keep: 2 });
});
it("is keyed by slug and records enabledAt only on the off→on transition", async () => {
const t1 = new Date("2026-10-17T10:00:00Z");
await saveBackupSettings("acme", { enabled: true, frequency: "weekly", time: "05:30", weekday: 3, keep: 4 }, t1);
await saveBackupSettings("zeta", { enabled: false, frequency: "daily", time: "01:00", weekday: 0, keep: 2 });
const acme = await readBackupSettings("acme");
expect(acme).toMatchObject({ enabled: true, frequency: "weekly", time: "05:30", weekday: 3, keep: 4, enabledAt: t1.toISOString() });
expect((await readBackupSettings("zeta")).enabled).toBe(false);
await saveBackupSettings("acme", { enabled: true, frequency: "daily", time: "05:30", weekday: 0, keep: 4 }, new Date("2026-10-18T10:00:00Z"));
expect((await readBackupSettings("acme")).enabledAt).toBe(t1.toISOString());
});
it("records attempts and outcomes without touching the schedule", async () => {
await saveBackupSettings("acme", { enabled: true, frequency: "daily", time: "02:00", weekday: 0, keep: 2 });
await recordBackupAttempt("acme", new Date("2026-10-17T02:00:00Z"));
await recordBackupOutcome("acme", { ok: false, message: "disk full", at: new Date("2026-10-17T02:05:00Z") });
expect(await readBackupSettings("acme")).toMatchObject({ time: "02:00", lastStatus: "failed", lastMessage: "disk full", lastSuccessAt: null });
await recordBackupOutcome("acme", { ok: true, message: "ok", at: new Date("2026-10-18T02:05:00Z") });
expect((await readBackupSettings("acme")).lastSuccessAt).toBe("2026-10-18T02:05:00.000Z");
});
it("validates input", () => {
const ok = { enabled: true, frequency: "daily" as const, time: "03:00", weekday: 0, keep: 2 };
expect(validateBackupSettingsInput(ok)).toBeNull();
expect(validateBackupSettingsInput({ ...ok, time: "25:00" })).toMatch(/HH:MM/);
expect(validateBackupSettingsInput({ ...ok, keep: 0 })).toMatch(/Keep between/);
expect(validateBackupSettingsInput({ ...ok, keep: 99 })).toMatch(/Keep between/);
expect(validateBackupSettingsInput({ ...ok, weekday: 9 })).toMatch(/day/);
});
});
@@ -0,0 +1,106 @@
import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { backupFileName, backupFilePath, listAllBackups, listBackups, parseBackupFileName, pruneBackups } from "../backup-store";
describe("backup file naming", () => {
it("round-trips slug, kind and UTC timestamp", () => {
const name = backupFileName("acme-cuts", "regular", new Date("2026-10-17T03:04:05.678Z"));
expect(name).toBe("acme-cuts__20261017T030405Z__regular.7z");
expect(parseBackupFileName(name)).toEqual({ slug: "acme-cuts", kind: "regular", createdAt: "2026-10-17T03:04:05Z" });
});
it("rejects names this module did not produce", () => {
expect(parseBackupFileName("acme__20261017T030405Z__regular.7z.partial")).toBeNull();
expect(parseBackupFileName("acme__20261017T030405Z__weird.7z")).toBeNull();
expect(parseBackupFileName("../etc/passwd")).toBeNull();
});
it("refuses to resolve a path for a mismatched slug or a traversal attempt", () => {
process.env.STACKS_DIR = "/stacks";
expect(() => backupFilePath("acme", "other__20261017T030405Z__regular.7z")).toThrow(/Invalid backup reference/);
expect(() => backupFilePath("acme", "../acme__20261017T030405Z__regular.7z")).toThrow(/Invalid backup reference/);
expect(() => backupFilePath("../x", "acme__20261017T030405Z__regular.7z")).toThrow(/Invalid backup reference/);
expect(backupFilePath("acme", "acme__20261017T030405Z__regular.7z")).toBe("/stacks/backups/acme/acme__20261017T030405Z__regular.7z");
});
});
describe("retention", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-backup-store-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function put(slug: string, kind: "regular" | "safety" | "final", iso: string) {
const dir = path.join(stacksDir, "backups", slug);
await mkdir(dir, { recursive: true });
const name = backupFileName(slug, kind, new Date(iso));
await writeFile(path.join(dir, name), "x");
return name;
}
it("keeps only the newest 2 regular backups and deletes the rest", async () => {
const oldest = await put("acme", "regular", "2026-10-10T03:00:00Z");
const middle = await put("acme", "regular", "2026-10-11T03:00:00Z");
const newest = await put("acme", "regular", "2026-10-12T03:00:00Z");
const removed = await pruneBackups("acme", "regular", 2);
expect(removed).toEqual([oldest]);
expect((await listBackups("acme")).map((b) => b.fileName)).toEqual([newest, middle]);
});
it("does nothing when at or below the limit", async () => {
await put("acme", "regular", "2026-10-10T03:00:00Z");
await put("acme", "regular", "2026-10-11T03:00:00Z");
expect(await pruneBackups("acme", "regular", 2)).toEqual([]);
});
it("honours a per-business keep count", async () => {
for (const day of ["10", "11", "12", "13"]) await put("acme", "regular", `2026-10-${day}T03:00:00Z`);
await pruneBackups("acme", "regular", 3);
expect(await listBackups("acme")).toHaveLength(3);
});
it("never lets regular pruning touch safety or final backups, or another business", async () => {
const safety = await put("acme", "safety", "2026-01-01T00:00:00Z");
const final = await put("acme", "final", "2026-01-02T00:00:00Z");
const other = await put("zeta", "regular", "2026-01-03T00:00:00Z");
for (const day of ["10", "11", "12"]) await put("acme", "regular", `2026-10-${day}T03:00:00Z`);
await pruneBackups("acme", "regular", 1);
const acme = (await listBackups("acme")).map((b) => b.fileName);
expect(acme).toContain(safety);
expect(acme).toContain(final);
expect(acme.filter((n) => n.endsWith("__regular.7z"))).toHaveLength(1);
expect((await listBackups("zeta")).map((b) => b.fileName)).toEqual([other]);
});
it("never prunes final backups", async () => {
for (const day of ["10", "11", "12"]) await put("acme", "final", `2026-10-${day}T03:00:00Z`);
expect(await pruneBackups("acme", "final", 1)).toEqual([]);
expect(await listBackups("acme")).toHaveLength(3);
});
it("ignores .partial files and the uploads folder when listing", async () => {
await put("acme", "regular", "2026-10-10T03:00:00Z");
await writeFile(path.join(stacksDir, "backups", "acme", "acme__20261011T030000Z__regular.7z.partial"), "x");
await mkdir(path.join(stacksDir, "backups", "_uploads"), { recursive: true });
await writeFile(path.join(stacksDir, "backups", "_uploads", "abc.7z"), "x");
expect(await listBackups("acme")).toHaveLength(1);
expect((await listAllBackups()).map((b) => b.slug)).toEqual(["acme"]);
expect(await readdir(path.join(stacksDir, "backups", "acme"))).toHaveLength(2);
});
});
+190
View File
@@ -0,0 +1,190 @@
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommandSpec } from "../process";
const containerExistsMock = vi.fn();
vi.mock("@/lib/docker", () => ({ containerExists: (...a: unknown[]) => containerExistsMock(...a) }));
const runToFileMock = vi.fn();
const runCaptureMock = vi.fn();
vi.mock("@/lib/process", () => ({
runToFile: (...a: unknown[]) => runToFileMock(...a),
runCapture: (...a: unknown[]) => runCaptureMock(...a),
}));
const sevenZipCreateMock = vi.fn();
const sevenZipTestMock = vi.fn();
vi.mock("@/lib/sevenzip", () => ({
sevenZipCreate: (...a: unknown[]) => sevenZipCreateMock(...a),
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
}));
import { performBackup } from "../backup";
import { backupFileName, listBackups } from "../backup-store";
import { saveBackupSettings } from "../backup-settings";
import { parseManifest } from "../backup-manifest";
const ENV = [
'BUSINESS_NAME="Acme Barbers"',
'DOMAIN="acme.example.com"',
'DATABASE_NAME="acme"',
'DATABASE_USER="acme"',
'DATABASE_PASSWORD="abc123"',
].join("\n");
describe("performBackup", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalRoot = process.env.MYSQL_ROOT_PASSWORD;
/** What the (mocked) 7zz saw in the staging dir at archive time. */
let staged: { files: string[]; manifest: string };
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-backup-"));
process.env.STACKS_DIR = stacksDir;
process.env.MYSQL_ROOT_PASSWORD = "root-pw";
await mkdir(path.join(stacksDir, "acme"), { recursive: true });
await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: next-chair-acme\n");
await writeFile(path.join(stacksDir, "acme", ".env"), ENV);
await mkdir(path.join(stacksDir, "infra", "sites"), { recursive: true });
await writeFile(path.join(stacksDir, "infra", "sites", "acme.caddy"), "acme.example.com {\n}\n");
containerExistsMock.mockReset().mockResolvedValue(true);
runCaptureMock.mockReset().mockResolvedValue({ stdout: "20261017180000_add_business_holidays\n", stderr: "" });
runToFileMock.mockReset().mockImplementation(async (spec: CommandSpec, file: string) => {
const isDump = spec.args.includes("mysqldump");
await writeFile(file, isDump ? "-- dump\nCREATE TABLE t (id int);\n-- Dump completed on 2026-10-17\n" : "TAR");
});
sevenZipTestMock.mockReset().mockResolvedValue(undefined);
sevenZipCreateMock.mockReset().mockImplementation(async (p: { archivePath: string; cwd: string }) => {
const files = (await readdir(p.cwd, { recursive: true })).map(String).sort();
staged = { files, manifest: await readFile(path.join(p.cwd, "manifest.json"), "utf8") };
await writeFile(p.archivePath, "7Z-ARCHIVE");
});
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalRoot === undefined) delete process.env.MYSQL_ROOT_PASSWORD;
else process.env.MYSQL_ROOT_PASSWORD = originalRoot;
await rm(stacksDir, { recursive: true, force: true });
});
const logs = () => {
const lines: string[] = [];
return { lines, ctx: { log: (l: string) => lines.push(l) } };
};
it("streams the dump and the media to files, packages them with a manifest, verifies, then renames into place", async () => {
const { ctx, lines } = logs();
const now = new Date("2026-10-17T03:00:00Z");
const result = await performBackup("acme", "regular", ctx, now);
// 1. mysqldump → db.sql, 2. docker cp → media.tar (both streamed, never captured in memory)
const [dumpSpec, dumpFile] = runToFileMock.mock.calls[0] as [CommandSpec, string];
expect(dumpSpec.args).toEqual(expect.arrayContaining(["mysqldump", "--single-transaction", "--routines", "--triggers", "acme"]));
expect(path.basename(dumpFile)).toBe("db.sql");
const [mediaSpec, mediaFile] = runToFileMock.mock.calls[1] as [CommandSpec, string];
expect(mediaSpec.args).toEqual(["cp", "next-chair-acme:/app/storage/media", "-"]);
expect(path.basename(mediaFile)).toBe("media.tar");
// 3. everything that goes into the archive
expect(staged.files).toEqual(["config", "config/.env", "config/compose.yaml", "config/site.caddy", "db.sql", "manifest.json", "media.tar"]);
const manifest = parseManifest(staged.manifest);
expect(manifest).toMatchObject({
slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
kind: "regular", lastMigration: "20261017180000_add_business_holidays", hasCaddySite: true, createdAt: now.toISOString(),
});
expect(staged.manifest).not.toContain("abc123"); // the DB password lives only in config/.env inside the archive
// 4. a plain archive (no password), written as .partial first
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; entries: string[] };
expect(create).not.toHaveProperty("passphrase");
expect(create.archivePath.endsWith(`${backupFileName("acme", "regular", now)}.partial`)).toBe(true);
expect(create.entries).toEqual(["manifest.json", "db.sql", "media.tar", "config"]);
// 5. verified (7zz t) the .partial, THEN renamed
expect(sevenZipTestMock).toHaveBeenCalledWith(create.archivePath);
const finalPath = create.archivePath.replace(/\.partial$/, "");
expect((await stat(finalPath)).isFile()).toBe(true);
expect((await stat(finalPath)).mode & 0o777).toBe(0o600);
await expect(stat(create.archivePath)).rejects.toThrow();
expect(result).toMatchObject({ slug: "acme", kind: "regular", fileName: backupFileName("acme", "regular", now) });
// no staging leftovers, and nothing secret in the log
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
expect(lines.join("\n")).not.toContain("abc123");
});
it("keeps only the newest 2 backups after a successful run", async () => {
const dir = path.join(stacksDir, "backups", "acme");
await mkdir(dir, { recursive: true });
for (const iso of ["2026-10-10T03:00:00Z", "2026-10-11T03:00:00Z", "2026-10-12T03:00:00Z"]) {
await writeFile(path.join(dir, backupFileName("acme", "regular", new Date(iso))), "old");
}
const result = await performBackup("acme", "regular", logs().ctx, new Date("2026-10-13T03:00:00Z"));
expect((await listBackups("acme")).map((b) => b.createdAt)).toEqual(["2026-10-13T03:00:00Z", "2026-10-12T03:00:00Z"]);
expect(result.pruned).toHaveLength(2);
});
it("uses the business's configured keep count", async () => {
await saveBackupSettings("acme", { enabled: true, frequency: "daily", time: "03:00", weekday: 0, keep: 3 });
const dir = path.join(stacksDir, "backups", "acme");
await mkdir(dir, { recursive: true });
for (const day of ["10", "11", "12"]) await writeFile(path.join(dir, backupFileName("acme", "regular", new Date(`2026-10-${day}T03:00:00Z`))), "old");
await performBackup("acme", "regular", logs().ctx, new Date("2026-10-13T03:00:00Z"));
expect(await listBackups("acme")).toHaveLength(3);
});
it("a failed verification leaves NO new backup behind and does not prune the good ones", async () => {
const dir = path.join(stacksDir, "backups", "acme");
await mkdir(dir, { recursive: true });
const good = [backupFileName("acme", "regular", new Date("2026-10-11T03:00:00Z")), backupFileName("acme", "regular", new Date("2026-10-12T03:00:00Z"))];
for (const name of good) await writeFile(path.join(dir, name), "good");
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data Error"));
await expect(performBackup("acme", "regular", logs().ctx, new Date("2026-10-13T03:00:00Z"))).rejects.toThrow(/Data Error/);
expect((await readdir(dir)).sort()).toEqual([...good].sort());
expect(await readdir(path.join(stacksDir, "manager", "tmp"))).toEqual([]);
});
it("refuses an incomplete dump (no completion marker)", async () => {
runToFileMock.mockImplementation(async (_spec: CommandSpec, file: string) => writeFile(file, "-- dump\nCREATE TABLE t (id int);\n"));
await expect(performBackup("acme", "regular", logs().ctx)).rejects.toThrow(/incomplete/);
expect(sevenZipCreateMock).not.toHaveBeenCalled();
});
it("reads media straight from the volume when the container is gone (a stopped stack is compose-down'd)", async () => {
containerExistsMock.mockResolvedValue(false);
await performBackup("acme", "regular", logs().ctx);
const [mediaSpec] = runToFileMock.mock.calls[1] as [CommandSpec];
expect(mediaSpec.args).toContain("next-chair-media-acme:/app/storage/media:ro");
});
it("safety backups prune only other safety backups and never the regular ones", async () => {
const dir = path.join(stacksDir, "backups", "acme");
await mkdir(dir, { recursive: true });
const regular = backupFileName("acme", "regular", new Date("2026-10-01T03:00:00Z"));
await writeFile(path.join(dir, regular), "r");
for (const day of ["02", "03", "04"]) await writeFile(path.join(dir, backupFileName("acme", "safety", new Date(`2026-10-${day}T03:00:00Z`))), "s");
await performBackup("acme", "safety", logs().ctx, new Date("2026-10-05T03:00:00Z"));
const names = (await readdir(dir)).sort();
expect(names).toContain(regular);
expect(names.filter((n) => n.includes("__safety"))).toHaveLength(2);
});
it("emits job steps in order when given a step wrapper", async () => {
const order: string[] = [];
await performBackup("acme", "regular", { log: () => undefined, step: async (id, fn) => (order.push(id), fn()) });
expect(order).toEqual(["dump", "media", "config", "archive", "verify", "finish"]);
});
});
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { generateBusinessEnv, type BusinessEnvValues } from "../compose-template";
const baseValues: BusinessEnvValues = {
name: "Acme Barbers",
domain: "acme.example.com",
databaseName: "acme",
databaseUser: "acme",
databasePassword: "db-password",
authSecret: "auth-secret",
installationToken: "the-installation-token-value",
cronSecret: "cron-secret",
calendarTokenEncryptionKey: "calendar-token-encryption-key",
};
describe("generateBusinessEnv — INSTALLATION_TOKEN", () => {
it("writes the given installation token into the generated .env", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('INSTALLATION_TOKEN="the-installation-token-value"');
});
it("never writes the token in a NEXT_PUBLIC_* (build-time-inlined) variable", () => {
const env = generateBusinessEnv(baseValues);
const publicLines = env.split("\n").filter((line) => line.startsWith("NEXT_PUBLIC_"));
for (const line of publicLines) {
expect(line).not.toContain("the-installation-token-value");
}
});
it("is the only place a business's INSTALLATION_TOKEN gets written — every business gets a distinct token if given one", () => {
const envA = generateBusinessEnv({ ...baseValues, installationToken: "token-a" });
const envB = generateBusinessEnv({ ...baseValues, domain: "other.example.com", installationToken: "token-b" });
expect(envA).toContain('INSTALLATION_TOKEN="token-a"');
expect(envB).toContain('INSTALLATION_TOKEN="token-b"');
expect(envA).not.toContain("token-b");
expect(envB).not.toContain("token-a");
});
});
describe("generateBusinessEnv — AUTH_URL", () => {
it("sets AUTH_URL explicitly to the business's own https domain — not left to header-based detection", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('AUTH_URL="https://acme.example.com"');
});
it("AUTH_URL always matches DOMAIN/NEXT_PUBLIC_APP_URL for a given business", () => {
const env = generateBusinessEnv({ ...baseValues, domain: "other-business.example.com" });
expect(env).toContain('DOMAIN="other-business.example.com"');
expect(env).toContain('AUTH_URL="https://other-business.example.com"');
expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"');
});
});
describe("generateBusinessEnv — SUPER_ADMIN_*", () => {
it("leaves SUPER_ADMIN_EMAIL blank when no global default is configured", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('SUPER_ADMIN_EMAIL=""');
expect(env).toContain('SUPER_ADMIN_FIRST_NAME=""');
expect(env).toContain('SUPER_ADMIN_LAST_NAME=""');
});
it("bakes in the global default Super Administrator when given one", () => {
const env = generateBusinessEnv({
...baseValues,
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
});
expect(env).toContain('SUPER_ADMIN_EMAIL="lazis002@gmail.com"');
expect(env).toContain('SUPER_ADMIN_FIRST_NAME="Stefan"');
expect(env).toContain('SUPER_ADMIN_LAST_NAME="Lazic"');
});
it("never writes a SUPER_ADMIN_PASSWORD line — sign-in is OTP-only", () => {
const env = generateBusinessEnv({
...baseValues,
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
});
expect(env).not.toContain("SUPER_ADMIN_PASSWORD");
});
});
describe("generateBusinessEnv — GOOGLE_CALENDAR_* / CALENDAR_TOKEN_ENCRYPTION_KEY", () => {
it("leaves the OAuth client blank when no global default is configured", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID=""');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET=""');
});
it("bakes in the global default OAuth client when given one", () => {
const env = generateBusinessEnv({
...baseValues,
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="abc.apps.googleusercontent.com"');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shh"');
});
it("always derives the redirect URI from this business's own domain, regardless of the shared OAuth client", () => {
const env = generateBusinessEnv({
...baseValues,
domain: "acme.example.com",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
});
it("always writes the given calendarTokenEncryptionKey, distinct per business, even when the OAuth client is shared", () => {
const envA = generateBusinessEnv({
...baseValues,
calendarTokenEncryptionKey: "key-a",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
const envB = generateBusinessEnv({
...baseValues,
domain: "other.example.com",
calendarTokenEncryptionKey: "key-b",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(envA).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-a"');
expect(envB).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-b"');
expect(envA).not.toContain("key-b");
expect(envB).not.toContain("key-a");
});
});
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
containerExistsCommand,
imageMigrationsCommand,
mediaCopyOutCommand,
mediaRestoreCommand,
mediaVolumeTarCommand,
mysqlImportCommand,
mysqlQueryCommand,
mysqldumpCommand,
} from "../docker-commands";
import { sevenZipCreateCommand, sevenZipExtractToStdoutCommand, sevenZipTestCommand } from "../sevenzip";
const ROOT_PASSWORD = "root-pw-for-tests";
describe("command construction", () => {
const saved = { pw: process.env.MYSQL_ROOT_PASSWORD, image: process.env.NEXTCHAIR_IMAGE, mysql: process.env.MYSQL_CONTAINER_NAME };
beforeEach(() => {
process.env.MYSQL_ROOT_PASSWORD = ROOT_PASSWORD;
delete process.env.NEXTCHAIR_IMAGE;
delete process.env.MYSQL_CONTAINER_NAME;
});
afterEach(() => {
for (const [key, value] of [["MYSQL_ROOT_PASSWORD", saved.pw], ["NEXTCHAIR_IMAGE", saved.image], ["MYSQL_CONTAINER_NAME", saved.mysql]] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("mysqldump: single-transaction + routines + triggers, as an argument array, password only in env", () => {
const spec = mysqldumpCommand("acme");
expect(spec.command).toBe("docker");
expect(spec.args).toEqual([
"exec", "-e", "MYSQL_PWD", "next-chair-mysql", "mysqldump", "-uroot",
"--single-transaction", "--routines", "--triggers", "--no-tablespaces", "--hex-blob",
"--default-character-set=utf8mb4", "acme",
]);
expect(spec.env).toEqual({ MYSQL_PWD: ROOT_PASSWORD });
expect(spec.args.join(" ")).not.toContain(ROOT_PASSWORD);
});
it("mysql import reads the dump on stdin (-i) and also keeps the password out of argv", () => {
const spec = mysqlImportCommand("acme");
expect(spec.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
expect(spec.args.join(" ")).not.toContain(ROOT_PASSWORD);
expect(spec.env?.MYSQL_PWD).toBe(ROOT_PASSWORD);
});
it("mysql query passes the SQL as a single argument (never through a shell)", () => {
const spec = mysqlQueryCommand("acme", "SELECT 1; SELECT 2");
expect(spec.args.at(-2)).toBe("-e");
expect(spec.args.at(-1)).toBe("SELECT 1; SELECT 2");
});
it("honours MYSQL_CONTAINER_NAME and NEXTCHAIR_IMAGE overrides", () => {
process.env.MYSQL_CONTAINER_NAME = "custom-mysql";
process.env.NEXTCHAIR_IMAGE = "registry.example/app:1.2";
expect(mysqldumpCommand("acme").args).toContain("custom-mysql");
expect(imageMigrationsCommand().args).toEqual(["run", "--rm", "--entrypoint", "ls", "registry.example/app:1.2", "/app/prisma/migrations"]);
});
it("media is streamed out with docker cp to stdout, or read from the volume when the container is gone", () => {
expect(mediaCopyOutCommand("next-chair-acme").args).toEqual(["cp", "next-chair-acme:/app/storage/media", "-"]);
expect(mediaVolumeTarCommand("acme").args).toEqual([
"run", "--rm", "-v", "next-chair-media-acme:/app/storage/media:ro", "--entrypoint", "tar", "nextchair-app:latest",
"-c", "-C", "/app/storage", "media",
]);
});
it("media restore unpacks stdin as root into the volume, then chowns to 1001:1001", () => {
const spec = mediaRestoreCommand("acme");
expect(spec.args.slice(0, 9)).toEqual(["run", "--rm", "-i", "--user", "0", "-v", "next-chair-media-acme:/app/storage/media", "--entrypoint", "sh"]);
expect(spec.args.at(-1)).toBe("tar -x -C /app/storage && chown -R 1001:1001 /app/storage/media");
});
it("container existence check", () => {
expect(containerExistsCommand("next-chair-acme").args).toEqual(["container", "inspect", "--format", "{{.Id}}", "next-chair-acme"]);
});
it("requires MYSQL_ROOT_PASSWORD", () => {
delete process.env.MYSQL_ROOT_PASSWORD;
expect(() => mysqldumpCommand("acme")).toThrow(/MYSQL_ROOT_PASSWORD/);
});
});
describe("7zz command construction", () => {
it("create: plain 7z (no password), listed entries, run from the staging dir", () => {
const spec = sevenZipCreateCommand({
archivePath: "/stacks/backups/acme/x.7z.partial",
cwd: "/stacks/manager/tmp/s1",
entries: ["manifest.json", "db.sql", "media.tar", "config"],
});
expect(spec.command).toBe("7zz");
expect(spec.cwd).toBe("/stacks/manager/tmp/s1");
expect(spec.args).toEqual([
"a", "-t7z", "-mx=3", "-y", "-bd",
"/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config",
]);
});
it("never asks 7zz for encryption or a password", () => {
const all = [
...sevenZipCreateCommand({ archivePath: "/a.7z", cwd: "/tmp", entries: ["x"] }).args,
...sevenZipTestCommand("/a.7z").args,
...sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args,
];
expect(all.some((arg) => arg.startsWith("-p") || arg.startsWith("-mhe"))).toBe(false);
});
it("test and stdout-extract commands", () => {
expect(sevenZipTestCommand("/a.7z").args).toEqual(["t", "-t7z", "-y", "-bd", "/a.7z"]);
expect(sevenZipExtractToStdoutCommand("/a.7z", "db.sql").args).toEqual([
"x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
]);
});
it("SEVENZIP_BIN overrides the binary", () => {
process.env.SEVENZIP_BIN = "/opt/7zz";
try {
expect(sevenZipTestCommand("/a.7z").command).toBe("/opt/7zz");
} finally {
delete process.env.SEVENZIP_BIN;
}
});
});
@@ -0,0 +1,89 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
isGoogleCalendarConfigComplete,
readGoogleCalendarSettingsView,
resolveGoogleCalendarSettings,
saveGoogleCalendarSettings,
saveEmailSettings,
readEmailSettingsView,
} from "../global-settings";
describe("global-settings — Google Calendar (encrypted client secret)", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-google-calendar-test-"));
process.env.STACKS_DIR = stacksDir;
process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64);
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey;
await rm(stacksDir, { recursive: true, force: true });
});
it("returns the empty default before anything has been saved", async () => {
expect(await readGoogleCalendarSettingsView()).toEqual({ clientId: "", hasClientSecret: false });
expect(await resolveGoogleCalendarSettings()).toBeNull();
});
it("saves and reads back the client ID/secret, never exposing the secret in the view", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const view = await readGoogleCalendarSettingsView();
expect(view).toEqual({ clientId: "abc.apps.googleusercontent.com", hasClientSecret: true });
const resolved = await resolveGoogleCalendarSettings();
expect(resolved).toEqual({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
});
it("never stores the client secret in plaintext on disk", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const { readFile } = await import("node:fs/promises");
const raw = await readFile(path.join(stacksDir, "manager", "global-settings.json"), "utf8");
expect(raw).not.toContain("top-secret");
});
it("a blank clientSecret on save keeps whatever secret is already stored", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "" });
const resolved = await resolveGoogleCalendarSettings();
expect(resolved?.clientSecret).toBe("top-secret");
});
it("saving does not clobber email settings already stored in the same file", async () => {
await saveEmailSettings({
host: "smtp.example.com",
port: 587,
username: "user",
password: "pw",
encryption: "starttls",
fromAddress: "from@example.com",
fromName: "",
replyTo: "",
});
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const email = await readEmailSettingsView();
expect(email.host).toBe("smtp.example.com");
expect(email.hasPassword).toBe(true);
});
it("isGoogleCalendarConfigComplete requires both a non-blank client ID and secret", () => {
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "shh" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "shh" })).toBe(true);
});
});
@@ -0,0 +1,63 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isSuperAdminConfigComplete, readSuperAdminSettings, saveSuperAdminSettings } from "../global-settings";
describe("global-settings — Super Administrator (not a secret, no encryption involved)", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-super-admin-test-"));
process.env.STACKS_DIR = stacksDir;
process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64);
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY;
else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey;
await rm(stacksDir, { recursive: true, force: true });
});
it("returns null before anything has been saved", async () => {
expect(await readSuperAdminSettings()).toBeNull();
});
it("saves and reads back the email/first/last name, lowercasing and trimming the email", async () => {
await saveSuperAdminSettings({ email: " Lazis002@Gmail.com ", firstName: " Stefan ", lastName: " Lazic " });
const saved = await readSuperAdminSettings();
expect(saved).toEqual({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
});
it("saving does not clobber email/push settings already stored in the same file", async () => {
const { saveEmailSettings } = await import("../global-settings");
await saveEmailSettings({
host: "smtp.example.com",
port: 587,
username: "user",
password: "pw",
encryption: "starttls",
fromAddress: "from@example.com",
fromName: "",
replyTo: "",
});
await saveSuperAdminSettings({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
const { readEmailSettingsView } = await import("../global-settings");
const email = await readEmailSettingsView();
expect(email.host).toBe("smtp.example.com");
expect(email.hasPassword).toBe(true);
});
it("isSuperAdminConfigComplete requires a non-blank email; name is optional", () => {
expect(isSuperAdminConfigComplete(null)).toBe(false);
expect(isSuperAdminConfigComplete({ email: "", firstName: "Stefan", lastName: "Lazic" })).toBe(false);
expect(isSuperAdminConfigComplete({ email: "lazis002@gmail.com", firstName: "", lastName: "" })).toBe(true);
});
});
@@ -0,0 +1,109 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: vi.fn(),
runMigrations: vi.fn(),
}));
describe("applyGoogleCalendarSettingsToAllBusinesses", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-gcal-rollout-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("writes the shared client ID/secret into every business and force-recreates each container", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="existing-key"'].join("\n") + "\n");
const results = await applyGoogleCalendarSettingsToAllBusinesses({
clientId: "shared-client-id",
clientSecret: "shared-client-secret",
});
expect(results).toEqual([{ slug: "acme", ok: true }]);
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="shared-client-id"');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shared-client-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("never touches GOOGLE_CALENDAR_REDIRECT_URI — it stays derived from each business's own domain", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack(
"acme",
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"'].join(
"\n",
) + "\n",
);
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
});
it("leaves an existing CALENDAR_TOKEN_ENCRYPTION_KEY untouched — it must never be shared across businesses", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"'].join("\n") + "\n");
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"');
});
it("backfills a fresh, distinct CALENDAR_TOKEN_ENCRYPTION_KEY for a business that never had one (pre-fix)", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
await createStack("other", ['BUSINESS_NAME="Other"', 'DOMAIN="other.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const envA = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
const envB = await readFile(path.join(stacksDir, "other", ".env"), "utf8");
const keyA = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envA)?.[1];
const keyB = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envB)?.[1];
expect(keyA).toBeTruthy();
expect(keyB).toBeTruthy();
expect(keyA).not.toBe(keyB);
});
it("reports per-business failures without aborting the rest", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"'].join("\n") + "\n");
await createStack("broken", ['BUSINESS_NAME="Broken"', 'DOMAIN="broken.example.com"'].join("\n") + "\n");
composeUpMock.mockImplementation(async (dir: string) => {
if (dir.includes("broken")) throw new Error("container recreate failed");
});
const results = await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
expect(results).toContainEqual({ slug: "acme", ok: true });
expect(results).toContainEqual({ slug: "broken", ok: false, error: "container recreate failed" });
});
});
@@ -0,0 +1,245 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_OPERATOR_SETTINGS } from "../global-settings";
import { classifyHealthBody, waitForHealth, type HealthSnapshot } from "../health";
import { applyObservation, buildAlertEmail, REMINDER_INTERVAL_MS, runHealthCheck, setIntentionallyStopped, type HealthRecord } from "../health-watcher";
const t0 = new Date("2026-10-17T12:00:00Z");
const at = (minutes: number) => new Date(t0.getTime() + minutes * 60_000);
const OK = { verdict: "ok" as const, reasons: [] };
const DOWN = { verdict: "down" as const, reasons: ["couldn't reach the app"] };
const DEGRADED = { verdict: "degraded" as const, reasons: ["12 notifications stale"] };
const opts = { dailyReminder: true };
describe("health state machine (pure)", () => {
it("one failed check does NOT alert; the second consecutive one does", () => {
const first = applyObservation("acme", undefined, DOWN, at(0), opts);
expect(first.alert).toBeNull();
expect(first.next.status).toBe("ok");
expect(first.next.pending).toEqual({ verdict: "down", count: 1 });
const second = applyObservation("acme", first.next, DOWN, at(3), opts);
expect(second.alert).toMatchObject({ type: "problem", from: "ok", to: "down" });
expect(second.next.status).toBe("down");
expect(second.next.pending).toBeNull();
});
it("a single blip in between resets the count", () => {
const a = applyObservation("acme", undefined, DOWN, at(0), opts);
const b = applyObservation("acme", a.next, OK, at(3), opts);
const c = applyObservation("acme", b.next, DOWN, at(6), opts);
expect(c.alert).toBeNull();
expect(c.next.pending?.count).toBe(1);
});
it("alerts on a change between bad states and on recovery", () => {
let rec: HealthRecord | undefined;
const run = (obs: typeof OK | typeof DOWN | typeof DEGRADED, min: number) => {
const r = applyObservation("acme", rec, obs, at(min), opts);
rec = r.next;
return r.alert;
};
run(DEGRADED, 0);
expect(run(DEGRADED, 3)).toMatchObject({ type: "problem", to: "degraded" });
run(DOWN, 6);
expect(run(DOWN, 9)).toMatchObject({ type: "changed", from: "degraded", to: "down" });
// Recovery reports when the bad period began (the down state started at minute 9).
expect(run(OK, 12)).toMatchObject({ type: "recovered", from: "down", to: "ok", since: at(9).toISOString() });
expect(run(OK, 15)).toBeNull();
});
it("sends a reminder once a day while still bad — not before, and not for ok", () => {
const bad = applyObservation("acme", applyObservation("acme", undefined, DOWN, at(0), opts).next, DOWN, at(3), opts);
const alerted: HealthRecord = { ...bad.next, lastAlertAt: at(3).toISOString() };
const early = applyObservation("acme", alerted, DOWN, new Date(at(3).getTime() + REMINDER_INTERVAL_MS - 60_000), opts);
expect(early.alert).toBeNull();
const due = applyObservation("acme", alerted, DOWN, new Date(at(3).getTime() + REMINDER_INTERVAL_MS), opts);
expect(due.alert).toMatchObject({ type: "reminder", to: "down" });
const off = applyObservation("acme", alerted, DOWN, new Date(at(3).getTime() + REMINDER_INTERVAL_MS), { dailyReminder: false });
expect(off.alert).toBeNull();
const healthy = applyObservation("acme", undefined, OK, new Date(t0.getTime() + 3 * REMINDER_INTERVAL_MS), opts);
expect(healthy.alert).toBeNull();
});
it("alert emails say what happened and never need a secret", () => {
const problem = buildAlertEmail({ type: "problem", slug: "acme", from: "ok", to: "down", reasons: ["connection refused"], since: at(3).toISOString(), raisedAt: at(3).toISOString() }, "Acme Barbers", "acme.example.com");
expect(problem.subject).toBe("Acme Barbers (acme.example.com) is DOWN");
expect(problem.text).toContain("connection refused");
const recovered = buildAlertEmail({ type: "recovered", slug: "acme", from: "down", to: "ok", reasons: [], since: at(9).toISOString(), raisedAt: at(9).toISOString() }, "Acme Barbers", null);
expect(recovered.subject).toBe("Acme Barbers has recovered");
expect(recovered.text).toContain(`was down since ${at(9).toISOString()}`);
});
});
describe("classifyHealthBody — consuming the app's /api/health", () => {
it("treats today's body (no queue/cron fields) as healthy by reachability", () => {
const s = classifyHealthBody(200, { status: "healthy", database: "reachable", installed: true, installedAt: "2026-01-01T00:00:00.000Z" });
expect(s).toMatchObject({ verdict: "ok", reasons: [], installed: true, queue: null, lastCronRunAt: null });
});
it("a non-200 or non-JSON response is down", () => {
expect(classifyHealthBody(503, { status: "unhealthy" }).verdict).toBe("down");
expect(classifyHealthBody(200, null).verdict).toBe("down");
});
it("consumes the planned queue/cron fields and degraded status with reasons", () => {
const s = classifyHealthBody(200, {
status: "degraded",
installed: true,
degradedReasons: ["12 notifications pending for over 10 minutes", "cron hasn't run since 11:00"],
queue: { stalePendingCount: 12, oldestPendingAgeSeconds: 900, stuckProcessingCount: 1, failedLast24h: 3 },
cron: { lastCronRunAt: "2026-10-17T11:00:00.000Z" },
});
expect(s.verdict).toBe("degraded");
expect(s.reasons).toHaveLength(2);
expect(s.queue).toEqual({ stalePendingCount: 12, oldestPendingAgeSeconds: 900, stuckProcessingCount: 1, failedLast24h: 3 });
expect(s.lastCronRunAt).toBe("2026-10-17T11:00:00.000Z");
});
it("status ok with the new fields present is still ok", () => {
expect(classifyHealthBody(200, { status: "ok", installed: true, queue: { stalePendingCount: 0 }, cron: {} }).verdict).toBe("ok");
});
it("degraded with no reasons still gets a reason", () => {
expect(classifyHealthBody(200, { status: "degraded" }).reasons).toEqual(["the app reports itself degraded"]);
});
});
describe("waitForHealth", () => {
const snap = (over: Partial<HealthSnapshot>): HealthSnapshot => ({ verdict: "ok", reasons: [], httpStatus: 200, installed: true, installedAt: null, lastCronRunAt: null, queue: null, ...over });
it("returns once the app answers 200", async () => {
const probe = vi.fn().mockResolvedValueOnce(snap({ httpStatus: null, verdict: "down" })).mockResolvedValueOnce(snap({}));
await expect(waitForHealth("acme", { probe, sleep: async () => undefined })).resolves.toMatchObject({ httpStatus: 200 });
expect(probe).toHaveBeenCalledTimes(2);
});
it("restore mode insists on installed:true — a reachable but un-installed app times out with a wizard warning", async () => {
const probe = vi.fn().mockResolvedValue(snap({ installed: false }));
await expect(waitForHealth("acme", { probe, sleep: async () => undefined, timeoutMs: 0, requireInstalled: true })).rejects.toThrow(/setup wizard/);
});
it("times out with the reasons when never reachable", async () => {
const probe = vi.fn().mockResolvedValue(snap({ httpStatus: null, verdict: "down", reasons: ["connection refused"] }));
await expect(waitForHealth("acme", { probe, sleep: async () => undefined, timeoutMs: 0 })).rejects.toThrow(/did not become healthy.*connection refused/);
});
});
describe("runHealthCheck (state file + email wiring)", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-health-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
const snapshot = (verdict: "ok" | "down" | "degraded"): HealthSnapshot => ({ verdict, reasons: verdict === "ok" ? [] : ["bad"], httpStatus: verdict === "down" ? null : 200, installed: true, installedAt: null, lastCronRunAt: null, queue: null });
function deps(overrides: Record<string, unknown> = {}) {
let verdict: "ok" | "down" | "degraded" = "down";
let clock = t0.getTime();
const sendEmail = vi.fn().mockResolvedValue({ ok: true, message: "sent" });
return {
sendEmail,
set: (v: typeof verdict) => (verdict = v),
advance: (min: number) => (clock += min * 60_000),
overrides: {
probe: async () => snapshot(verdict),
sendEmail,
now: () => new Date(clock),
isBusy: async () => false,
readSettings: async () => ({ ...DEFAULT_OPERATOR_SETTINGS, alertEmail: "ops@example.com" }),
describe: async () => ({ label: "Acme", domain: "acme.example.com" }),
...overrides,
},
};
}
it("emails once after two failures, once on recovery, and nothing in between", async () => {
const d = deps();
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).not.toHaveBeenCalled();
d.advance(3);
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).toHaveBeenCalledTimes(1);
expect(d.sendEmail.mock.calls[0]?.[0]).toContain("is DOWN");
d.advance(3);
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).toHaveBeenCalledTimes(1);
d.set("ok");
d.advance(3);
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).toHaveBeenCalledTimes(2);
expect(d.sendEmail.mock.calls[1]?.[0]).toContain("recovered");
});
it("repeats once a day while still bad", async () => {
const d = deps();
await runHealthCheck("acme", d.overrides);
d.advance(3);
await runHealthCheck("acme", d.overrides);
d.advance(23 * 60);
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).toHaveBeenCalledTimes(1);
d.advance(60 + 3);
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).toHaveBeenCalledTimes(2);
expect(d.sendEmail.mock.calls[1]?.[0]).toContain("Still DOWN");
});
it("retries an alert whose email failed, instead of losing it", async () => {
const d = deps();
d.sendEmail.mockResolvedValueOnce({ ok: false, message: "SMTP down" });
await runHealthCheck("acme", d.overrides);
d.advance(3);
await runHealthCheck("acme", d.overrides); // alert raised, send fails
expect(d.sendEmail).toHaveBeenCalledTimes(1);
d.advance(3);
await runHealthCheck("acme", d.overrides); // same state, but the unsent alert goes out now
expect(d.sendEmail).toHaveBeenCalledTimes(2);
d.advance(3);
await runHealthCheck("acme", d.overrides);
expect(d.sendEmail).toHaveBeenCalledTimes(2);
});
it("sends nothing when no alert address is set or the toggle is off", async () => {
const noAddress = deps({ readSettings: async () => ({ ...DEFAULT_OPERATOR_SETTINGS, alertEmail: "" }) });
await runHealthCheck("acme", noAddress.overrides);
noAddress.advance(3);
await runHealthCheck("acme", noAddress.overrides);
expect(noAddress.sendEmail).not.toHaveBeenCalled();
const toggledOff = deps({ readSettings: async () => ({ ...DEFAULT_OPERATOR_SETTINGS, alertEmail: "ops@example.com", alertOnHealth: false }) });
await runHealthCheck("zeta", toggledOff.overrides);
toggledOff.advance(3);
await runHealthCheck("zeta", toggledOff.overrides);
expect(toggledOff.sendEmail).not.toHaveBeenCalled();
});
it("doesn't probe a business that is mid-job or was stopped on purpose", async () => {
const probe = vi.fn().mockResolvedValue(snapshot("down"));
const busy = deps({ isBusy: async () => true, probe });
expect(await runHealthCheck("acme", busy.overrides)).toMatchObject({ skipped: expect.stringContaining("job") });
await setIntentionallyStopped("zeta", true);
const stopped = deps({ probe });
expect(await runHealthCheck("zeta", stopped.overrides)).toMatchObject({ skipped: "intentionally stopped" });
expect(probe).not.toHaveBeenCalled();
await setIntentionallyStopped("zeta", false);
await runHealthCheck("zeta", stopped.overrides);
expect(probe).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,146 @@
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchInstallationStatus, getInstallationToken, resolveInstallationView } from "../installation-status";
describe("fetchInstallationStatus", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("reports installed with installedAt when the tenant's /api/health says installed: true", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "healthy", database: "reachable", installed: true, installedAt: "2026-01-15T10:00:00.000Z" }),
}) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "installed", installedAt: "2026-01-15T10:00:00.000Z" });
});
it("reports not_installed (with no installedAt) when installed: false", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: "healthy", database: "reachable", installed: false, installedAt: null }),
}) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "not_installed", installedAt: null });
});
it("reports unknown — never not_installed — when the fetch throws (container down/unreachable)", async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "unknown", installedAt: null });
});
it("reports unknown when the response isn't ok (e.g. 503 unhealthy)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "unknown", installedAt: null });
});
it("reports unknown for a malformed body (no boolean `installed` field)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ unexpected: true }) }) as unknown as typeof fetch;
const result = await fetchInstallationStatus("acme");
expect(result).toEqual({ status: "unknown", installedAt: null });
});
it("targets the internal docker network hostname for the given slug, not the public domain", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) });
globalThis.fetch = fetchMock as unknown as typeof fetch;
await fetchInstallationStatus("acme-barbers");
expect(fetchMock).toHaveBeenCalledWith("http://next-chair-acme-barbers:3000/api/health", expect.any(Object));
});
});
describe("getInstallationToken / resolveInstallationView", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalFetch = globalThis.fetch;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-test-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
globalThis.fetch = originalFetch;
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function writeStackEnv(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, ".env"), envContent, "utf8");
}
it("reads the existing token straight from that business's own .env — an already-generated token is preserved, not regenerated", async () => {
await writeStackEnv("acme", 'BUSINESS_NAME="Acme"\nINSTALLATION_TOKEN="existing-token-value"\n');
const token = await getInstallationToken("acme");
expect(token).toBe("existing-token-value");
});
it("returns null (not an error) for a business with no token in its .env", async () => {
await writeStackEnv("legacy-biz", 'BUSINESS_NAME="Legacy"\n');
const token = await getInstallationToken("legacy-biz");
expect(token).toBeNull();
});
it("returns null for a business directory that doesn't exist", async () => {
const token = await getInstallationToken("does-not-exist");
expect(token).toBeNull();
});
it("tenant isolation: never returns another business's token", async () => {
await writeStackEnv("tenant-a", 'INSTALLATION_TOKEN="token-for-a"\n');
await writeStackEnv("tenant-b", 'INSTALLATION_TOKEN="token-for-b"\n');
expect(await getInstallationToken("tenant-a")).toBe("token-for-a");
expect(await getInstallationToken("tenant-b")).toBe("token-for-b");
});
it("resolveInstallationView never includes a token once installed — the token is not even read from disk in that branch", async () => {
await writeStackEnv("acme", 'INSTALLATION_TOKEN="should-never-appear"\n');
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ installed: true, installedAt: "2026-01-15T10:00:00.000Z" }),
}) as unknown as typeof fetch;
const view = await resolveInstallationView("acme", "acme.example.com");
expect(view).toEqual({ kind: "installed", installedAt: "2026-01-15T10:00:00.000Z" });
expect(JSON.stringify(view)).not.toContain("should-never-appear");
});
it("resolveInstallationView surfaces the token pre-install, with the public install URL", async () => {
await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n');
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ installed: false }) }) as unknown as typeof fetch;
const view = await resolveInstallationView("acme", "acme.example.com");
expect(view).toEqual({ kind: "not_installed", token: "real-token", installUrl: "https://acme.example.com/install" });
});
it("resolveInstallationView still surfaces the token (not hidden) when status can't be confirmed", async () => {
await writeStackEnv("acme", 'INSTALLATION_TOKEN="real-token"\n');
globalThis.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const view = await resolveInstallationView("acme", "acme.example.com");
expect(view.kind).toBe("unknown");
expect(view).toMatchObject({ token: "real-token" });
});
});
+141
View File
@@ -0,0 +1,141 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { pruneOldJobs, readJob, recoverInterruptedJobs, startJob } from "../jobs";
import { BusinessLockedError, getActiveLock, readLock } from "../locks";
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
describe("job runner", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-jobs-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
const steps = [
{ id: "one", label: "First" },
{ id: "two", label: "Second" },
{ id: "three", label: "Third" },
];
it("writes a pollable record with step states and a log tail, holding the lock only while running", async () => {
const gate = deferred();
const { jobId, done } = await startJob({
kind: "update",
slug: "acme",
title: "Update acme",
steps,
run: async (ctx) => {
await ctx.step("one", async () => ctx.log("hello from step one"));
await ctx.step("two", async () => gate.promise);
ctx.skip("three", "not needed");
return { ok: true };
},
});
// Visible immediately, mid-run: step one done, step two running, lock held.
await new Promise((r) => setTimeout(r, 30));
const midway = await readJob(jobId);
expect(midway?.status).toBe("running");
expect(midway?.steps.map((s) => s.status)).toEqual(["done", "running", "pending"]);
expect(midway?.logTail.join("\n")).toContain("hello from step one");
expect((await getActiveLock("acme"))?.jobId).toBe(jobId);
gate.resolve();
const finished = await done;
expect(finished.status).toBe("succeeded");
const onDisk = await readJob(jobId);
expect(onDisk?.steps.map((s) => s.status)).toEqual(["done", "done", "skipped"]);
expect(onDisk?.result).toEqual({ ok: true });
expect(onDisk?.finishedAt).not.toBeNull();
expect(await readLock("acme")).toBeNull();
});
it("records a failure on the failing step, skips the rest, and still releases the lock", async () => {
const { jobId, done } = await startJob({
kind: "backup",
slug: "acme",
title: "Back up acme",
steps,
run: async (ctx) => {
await ctx.step("one", async () => undefined);
await ctx.step("two", async () => {
throw new Error("mysqldump exited with code 2");
});
},
});
const finished = await done;
expect(finished.status).toBe("failed");
expect(finished.error).toBe("mysqldump exited with code 2");
expect((await readJob(jobId))?.steps.map((s) => s.status)).toEqual(["done", "failed", "skipped"]);
expect((await readJob(jobId))?.steps[1]?.detail).toBe("mysqldump exited with code 2");
expect(await readLock("acme")).toBeNull();
});
it("a second job on the same business is rejected while the first runs, and allowed after", async () => {
const gate = deferred();
const first = await startJob({ kind: "backup", slug: "acme", title: "a", steps, run: async () => gate.promise });
await expect(startJob({ kind: "delete", slug: "acme", title: "b", steps, run: async () => undefined })).rejects.toBeInstanceOf(BusinessLockedError);
// A different business is unaffected.
const other = await startJob({ kind: "update", slug: "zeta", title: "c", steps, run: async () => undefined });
await other.done;
gate.resolve();
await first.done;
const again = await startJob({ kind: "delete", slug: "acme", title: "d", steps, run: async () => undefined });
expect((await again.done).status).toBe("succeeded");
});
it("rejects unknown job ids without touching the filesystem", async () => {
expect(await readJob("../../etc/passwd")).toBeNull();
expect(await readJob("nope")).toBeNull();
});
it("recoverInterruptedJobs fails jobs left 'running' by a dead process and frees their locks", async () => {
const jobsPath = path.join(stacksDir, "manager", "jobs");
const locksPath = path.join(stacksDir, "manager", "locks");
await mkdir(jobsPath, { recursive: true });
await mkdir(locksPath, { recursive: true });
const id = "abc123-deadbeef";
await writeFile(
path.join(jobsPath, `${id}.json`),
JSON.stringify({
id, kind: "restore", slug: "acme", title: "Restore acme", status: "running", createdAt: new Date().toISOString(), finishedAt: null,
steps: [{ id: "a", label: "A", status: "running", startedAt: null, finishedAt: null, detail: null }, { id: "b", label: "B", status: "pending", startedAt: null, finishedAt: null, detail: null }],
logTail: [], error: null, result: null,
}),
);
await writeFile(path.join(locksPath, "acme.json"), JSON.stringify({ slug: "acme", jobId: id, kind: "restore", acquiredAt: new Date().toISOString(), heartbeatAt: new Date().toISOString() }));
expect(await recoverInterruptedJobs()).toBe(1);
const job = await readJob(id);
expect(job?.status).toBe("failed");
expect(job?.error).toMatch(/Interrupted/);
expect(job?.steps.map((s) => s.status)).toEqual(["failed", "skipped"]);
expect(await readLock("acme")).toBeNull();
});
it("prunes only old, finished job records", async () => {
const { jobId, done } = await startJob({ kind: "update", slug: "acme", title: "x", steps, run: async () => undefined });
await done;
expect(await pruneOldJobs(14 * 24 * 3600 * 1000)).toBe(0);
expect(await pruneOldJobs(14 * 24 * 3600 * 1000, new Date(Date.now() + 15 * 24 * 3600 * 1000))).toBe(1);
expect(await readJob(jobId)).toBeNull();
expect(await readFile(path.join(stacksDir, "manager", "jobs", "x.json")).catch(() => null)).toBeNull();
});
});
+83
View File
@@ -0,0 +1,83 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { acquireLock, assertNotLocked, BusinessLockedError, getActiveLock, isLockStale, readLock, releaseLock, STALE_LOCK_MS, touchLock } from "../locks";
describe("per-business lock", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-locks-"));
process.env.STACKS_DIR = stacksDir;
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
it("lets one job hold a business and rejects a second one (Update vs Delete vs second backup)", async () => {
await acquireLock("acme", { jobId: "j1", kind: "backup" });
await expect(acquireLock("acme", { jobId: "j2", kind: "update" })).rejects.toBeInstanceOf(BusinessLockedError);
await expect(acquireLock("acme", { jobId: "j3", kind: "delete" })).rejects.toThrow(/backup job \(j1\)/);
await expect(assertNotLocked("acme")).rejects.toBeInstanceOf(BusinessLockedError);
});
it("locks are per business", async () => {
await acquireLock("acme", { jobId: "j1", kind: "backup" });
await expect(acquireLock("zeta", { jobId: "j2", kind: "backup" })).resolves.toMatchObject({ slug: "zeta" });
});
it("only two simultaneous acquirers can't both win", async () => {
const results = await Promise.allSettled([
acquireLock("acme", { jobId: "a", kind: "backup" }),
acquireLock("acme", { jobId: "b", kind: "update" }),
acquireLock("acme", { jobId: "c", kind: "restore" }),
]);
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
});
it("releases only for the owner, then can be re-acquired", async () => {
await acquireLock("acme", { jobId: "j1", kind: "backup" });
await releaseLock("acme", "someone-else");
expect(await readLock("acme")).not.toBeNull();
await releaseLock("acme", "j1");
expect(await readLock("acme")).toBeNull();
await expect(acquireLock("acme", { jobId: "j2", kind: "update" })).resolves.toBeDefined();
});
describe("stale-lock rule", () => {
it("a lock is stale once its heartbeat is older than the threshold", () => {
const now = new Date("2026-10-17T12:00:00Z");
const lock = { slug: "acme", jobId: "j1", kind: "backup", acquiredAt: now.toISOString(), heartbeatAt: now.toISOString() };
expect(isLockStale(lock, new Date(now.getTime() + STALE_LOCK_MS - 1000))).toBe(false);
expect(isLockStale(lock, new Date(now.getTime() + STALE_LOCK_MS + 1000))).toBe(true);
expect(isLockStale({ ...lock, heartbeatAt: "garbage" }, now)).toBe(true);
});
it("a stale lock is taken over by the next job; a fresh one is not", async () => {
const old = new Date(Date.now() - STALE_LOCK_MS - 60_000);
await acquireLock("acme", { jobId: "dead", kind: "restore" }, old);
expect(await getActiveLock("acme")).toBeNull();
await expect(assertNotLocked("acme")).resolves.toBeUndefined();
const taken = await acquireLock("acme", { jobId: "alive", kind: "update" });
expect(taken.jobId).toBe("alive");
await expect(acquireLock("acme", { jobId: "third", kind: "backup" })).rejects.toBeInstanceOf(BusinessLockedError);
});
it("a heartbeat keeps a long job's lock alive; a dead owner can't release its successor's lock", async () => {
const start = new Date(Date.now() - STALE_LOCK_MS - 60_000);
await acquireLock("acme", { jobId: "slow", kind: "backup" }, start);
await touchLock("acme", "slow", new Date());
expect(await getActiveLock("acme")).not.toBeNull();
// Pretend it went stale and was taken over.
await releaseLock("acme", "slow");
await acquireLock("acme", { jobId: "successor", kind: "update" });
await releaseLock("acme", "slow");
expect((await readLock("acme"))?.jobId).toBe("successor");
});
});
});
@@ -0,0 +1,102 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const calls: string[] = [];
vi.mock("@/lib/docker", () => ({
composeUp: vi.fn(async (_dir: string, opts?: unknown) => calls.push(`composeUp:${JSON.stringify(opts)}`)),
}));
const waitForHealthMock = vi.fn();
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
const performBackupMock = vi.fn();
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a), BACKUP_STEPS: [] }));
vi.mock("@/lib/global-settings", () => ({
readOperatorSettings: vi.fn(async () => ({ alertEmail: "", alertOnBackupFailure: true })),
}));
vi.mock("@/lib/provisioning", () => ({ deleteBusiness: vi.fn(async () => (calls.push("deleteBusiness"), { slug: "acme", warnings: [] })) }));
vi.mock("@/lib/operator-email", () => ({ sendOperatorEmail: vi.fn() }));
import { BusinessLockedError } from "../locks";
import { startDeleteJob, startUpdateJob } from "../operations";
import { startJob } from "../jobs";
import { readJob } from "../jobs";
describe("Update and Delete jobs", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operations-"));
process.env.STACKS_DIR = stacksDir;
await mkdir(path.join(stacksDir, "acme"), { recursive: true });
await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: x\n");
calls.length = 0;
waitForHealthMock.mockReset().mockImplementation(async () => (calls.push("waitForHealth"), undefined));
performBackupMock.mockReset().mockImplementation(async (_slug: string, kind: string) => (calls.push(`backup:${kind}`), { fileName: `f-${kind}.7z` }));
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
it("Update without backup: recreate container → wait for /api/health → done", async () => {
const { jobId, done } = await startUpdateJob("acme", false);
const job = await done;
expect(job.status).toBe("succeeded");
expect(job.steps.map((s) => [s.id, s.status])).toEqual([["recreate", "done"], ["health", "done"], ["done", "done"]]);
expect(calls).toEqual(['composeUp:{"forceRecreate":true}', "waitForHealth"]);
expect((await readJob(jobId))?.title).toBe("Update acme");
});
it("Update with 'back up first': a safety backup runs before the container is touched", async () => {
const { done } = await startUpdateJob("acme", true);
const job = await done;
expect(job.steps.map((s) => s.id)).toEqual(["backup", "recreate", "health", "done"]);
expect(calls).toEqual(["backup:safety", 'composeUp:{"forceRecreate":true}', "waitForHealth"]);
});
it("a failed pre-update backup stops the update before the container is recreated", async () => {
performBackupMock.mockRejectedValue(new Error("disk full"));
const job = await (await startUpdateJob("acme", true)).done;
expect(job.status).toBe("failed");
expect(job.error).toBe("disk full");
expect(calls.some((c) => c.startsWith("composeUp"))).toBe(false);
expect(job.steps.map((s) => s.status)).toEqual(["failed", "skipped", "skipped", "skipped"]);
});
it("an unhealthy app after the recreate fails the job with the health error", async () => {
waitForHealthMock.mockRejectedValue(new Error('"acme" did not become healthy within 180s.'));
const job = await (await startUpdateJob("acme", false)).done;
expect(job.status).toBe("failed");
expect(job.steps.map((s) => s.status)).toEqual(["done", "failed", "skipped"]);
});
it("Update refuses to run on top of another job for the same business", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const running = await startJob({ kind: "backup", slug: "acme", title: "x", steps: [], run: async () => gate });
await expect(startUpdateJob("acme", false)).rejects.toBeInstanceOf(BusinessLockedError);
await expect(startDeleteJob("acme", false)).rejects.toBeInstanceOf(BusinessLockedError);
release();
await running.done;
});
it("Delete with a final backup: backs up first, deletes only afterwards; a failed backup aborts the delete", async () => {
const ok = await (await startDeleteJob("acme", true)).done;
expect(ok.status).toBe("succeeded");
expect(calls).toEqual(["backup:final", "deleteBusiness"]);
calls.length = 0;
performBackupMock.mockRejectedValue(new Error("mysqldump failed"));
const failed = await (await startDeleteJob("acme", true)).done;
expect(failed.status).toBe("failed");
expect(calls).not.toContain("deleteBusiness");
});
it("Delete without a final backup skips that step", async () => {
const job = await (await startDeleteJob("acme", false)).done;
expect(job.steps.map((s) => s.status)).toEqual(["skipped", "done"]);
expect(calls).toEqual(["deleteBusiness"]);
});
});
@@ -0,0 +1,79 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: vi.fn(),
runMigrations: vi.fn(),
}));
describe("applyOperatorAlertEmailToAllBusinesses", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-operator-rollout-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("writes the alert address into each business and restarts it", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("acme", 'BUSINESS_NAME="Acme"\nDOMAIN="acme.example.com"\n');
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
expect(results).toEqual([{ slug: "acme", ok: true }]);
expect(await readFile(path.join(stacksDir, "acme", ".env"), "utf8")).toContain(
'OPERATOR_ALERT_EMAIL="ops@example.com"',
);
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), {
forceRecreate: true,
});
});
it("leaves a business that already has this address alone — no rewrite, no restart", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("acme", 'BUSINESS_NAME="Acme"\nOPERATOR_ALERT_EMAIL="ops@example.com"\n');
const results = await applyOperatorAlertEmailToAllBusinesses("ops@example.com");
expect(results).toEqual([{ slug: "acme", ok: true, unchanged: true }]);
expect(composeUpMock).not.toHaveBeenCalled();
});
it("an empty address turns alerts off, and one failing business doesn't stop the rest", async () => {
const { applyOperatorAlertEmailToAllBusinesses } = await import("../operator-rollout");
await createStack("alpha", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
await createStack("beta", 'OPERATOR_ALERT_EMAIL="ops@example.com"\n');
composeUpMock.mockRejectedValueOnce(new Error("docker unavailable"));
const results = await applyOperatorAlertEmailToAllBusinesses("");
expect(results).toHaveLength(2);
expect(results.filter((r) => r.ok)).toHaveLength(1);
expect(results.find((r) => !r.ok)?.error).toContain("docker unavailable");
expect(await readFile(path.join(stacksDir, "beta", ".env"), "utf8")).toContain(
'OPERATOR_ALERT_EMAIL=""',
);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { CommandError, runCapture, runFromFile, runPipeline, runToFile } from "../process";
const node = (script: string, env?: Record<string, string>) => ({ command: process.execPath, args: ["-e", script], env });
describe("streaming subprocess helpers", () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "nc-manager-process-"));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it("runToFile streams far more than execFile's default buffer without buffering it", async () => {
const out = path.join(dir, "big.bin");
// ~30 MB, written in chunks — would blow execFile's 1 MB default maxBuffer.
await runToFile(node("const b=Buffer.alloc(1024*1024,97);let n=30;(function w(){while(n>0){n--;if(!process.stdout.write(b))return process.stdout.once('drain',w)}})()"), out);
expect((await readFile(out)).length).toBe(30 * 1024 * 1024);
});
it("runToFile rejects on a non-zero exit with the stderr tail (and no command arguments)", async () => {
const out = path.join(dir, "x");
const error = await runToFile(node("console.error('boom: access denied');process.exit(3)", { MYSQL_PWD: "hunter2" }), out).catch((e: unknown) => e);
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).exitCode).toBe(3);
expect((error as CommandError).message).toContain("boom: access denied");
expect((error as CommandError).message).not.toContain("hunter2");
});
it("passes env to the child but not via arguments", async () => {
const { stdout } = await runCapture(node("process.stdout.write(process.env.MYSQL_PWD)", { MYSQL_PWD: "from-env" }));
expect(stdout).toBe("from-env");
});
it("runFromFile feeds a file to stdin", async () => {
const input = path.join(dir, "in.txt");
const output = path.join(dir, "out.txt");
await writeFile(input, "line one\nline two\n");
await runFromFile(node(`require('fs').writeFileSync(${JSON.stringify(output)}, require('fs').readFileSync(0))`), input);
expect(await readFile(output, "utf8")).toBe("line one\nline two\n");
});
it("runPipeline pipes the producer's stdout into the consumer's stdin", async () => {
const output = path.join(dir, "piped.txt");
await runPipeline(
node("process.stdout.write('hello pipeline')"),
node(`require('fs').writeFileSync(${JSON.stringify(output)}, require('fs').readFileSync(0))`),
);
expect(await readFile(output, "utf8")).toBe("hello pipeline");
});
it("runPipeline fails when the producer fails even if the consumer 'succeeds' on the truncated stream", async () => {
await expect(runPipeline(node("process.stdout.write('partial');console.error('Wrong password');process.exit(2)"), node("require('fs').readFileSync(0)"))).rejects.toThrow(/Wrong password/);
});
it("runPipeline fails when the consumer fails", async () => {
await expect(runPipeline(node("process.stdout.write('x'.repeat(1000))"), node("console.error('ERROR 1064 syntax');process.exit(1)"))).rejects.toThrow(/1064/);
});
it("reports a missing binary clearly", async () => {
await expect(runCapture({ command: "definitely-not-a-real-binary-xyz", args: [] })).rejects.toThrow(/Couldn't run definitely-not-a-real-binary-xyz/);
});
it("runCapture caps output", async () => {
await expect(runCapture(node("process.stdout.write('x'.repeat(5000))"), { maxBytes: 100 })).rejects.toThrow(/more than 100 bytes/);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { randomHex } from "../random";
describe("randomHex", () => {
it("returns a hex string of the requested byte length", () => {
const value = randomHex(32);
expect(value).toMatch(/^[a-f0-9]+$/);
expect(value).toHaveLength(64);
});
it("is sufficiently long/unpredictable — two calls never collide", () => {
const a = randomHex(32);
const b = randomHex(32);
expect(a).not.toBe(b);
});
});
+223
View File
@@ -0,0 +1,223 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CommandSpec } from "../process";
/** Every side effect, in the order it happens — the restore's contract. */
const calls: string[] = [];
const record = (label: string) => calls.push(label);
vi.mock("@/lib/docker", () => ({
composeDown: vi.fn(async (_dir: string, opts?: unknown) => record(`composeDown${opts ? ":" + JSON.stringify(opts) : ""}`)),
composeCreate: vi.fn(async () => record("composeCreate")),
composeUp: vi.fn(async () => record("composeUp")),
listImageMigrations: vi.fn(async () => {
record("listImageMigrations");
return ["20260912165903_add_staff_selected_by_customer", "20261017180000_add_business_holidays"];
}),
}));
const assertDomainAvailableMock = vi.fn();
const recreateDatabaseMock = vi.fn();
vi.mock("@/lib/provisioning", () => ({
assertDomainAvailable: (...a: unknown[]) => assertDomainAvailableMock(...a),
recreateDatabaseAndUser: (...a: unknown[]) => recreateDatabaseMock(...a),
writeCaddySiteAndReload: vi.fn(async (slug: string, domain: string) => record(`writeCaddy:${slug}:${domain}`)),
}));
const runPipelineMock = vi.fn();
vi.mock("@/lib/process", () => ({ runPipeline: (...a: unknown[]) => runPipelineMock(...a) }));
const sevenZipTestMock = vi.fn();
const sevenZipReadMemberMock = vi.fn();
vi.mock("@/lib/sevenzip", async (importOriginal) => ({
...(await importOriginal<typeof import("../sevenzip")>()),
sevenZipTest: (...a: unknown[]) => sevenZipTestMock(...a),
sevenZipReadMember: (...a: unknown[]) => sevenZipReadMemberMock(...a),
}));
const waitForHealthMock = vi.fn();
vi.mock("@/lib/health", () => ({ waitForHealth: (...a: unknown[]) => waitForHealthMock(...a) }));
const performBackupMock = vi.fn();
vi.mock("@/lib/backup", () => ({ performBackup: (...a: unknown[]) => performBackupMock(...a) }));
import { performRestore, RESTORE_STEPS } from "../restore";
const DB_PASSWORD = "f".repeat(32);
const ARCHIVE = "/stacks/backups/acme/acme__20261017T030000Z__regular.7z";
const BACKED_UP_ENV = ['BUSINESS_NAME="Acme Barbers"', 'DOMAIN="acme.example.com"', 'DATABASE_NAME="acme"', 'DATABASE_USER="acme"', `DATABASE_PASSWORD="${DB_PASSWORD}"`, 'AUTH_SECRET="s3cret-from-backup"'].join("\n") + "\n";
function manifest(over: Record<string, unknown> = {}) {
return JSON.stringify({
formatVersion: 1, slug: "acme", name: "Acme Barbers", domain: "acme.example.com", databaseName: "acme", databaseUser: "acme",
createdAt: "2026-10-17T03:00:00.000Z", kind: "regular", lastMigration: "20261017180000_add_business_holidays", appImage: null, hasCaddySite: true, ...over,
});
}
describe("performRestore", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalRoot = process.env.MYSQL_ROOT_PASSWORD;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-restore-"));
process.env.STACKS_DIR = stacksDir;
process.env.MYSQL_ROOT_PASSWORD = "root-pw";
calls.length = 0;
assertDomainAvailableMock.mockReset().mockImplementation(async () => record("assertDomainAvailable"));
recreateDatabaseMock.mockReset().mockImplementation(async () => record("recreateDatabase"));
runPipelineMock.mockReset().mockImplementation(async (source: CommandSpec) => record(`pipeline:${source.args.at(-1)}`));
sevenZipTestMock.mockReset().mockImplementation(async () => record("7zz-test"));
sevenZipReadMemberMock.mockReset().mockImplementation(async (_a: string, member: string) => {
record(`read:${member}`);
return member === "manifest.json" ? manifest() : BACKED_UP_ENV;
});
waitForHealthMock.mockReset().mockImplementation(async (_slug: string, opts: unknown) => record(`waitForHealth:${JSON.stringify((opts as { requireInstalled?: boolean }).requireInstalled)}`));
performBackupMock.mockReset().mockImplementation(async (slug: string, kind: string) => {
record(`safetyBackup:${slug}:${kind}`);
return { fileName: "acme__20261017T040000Z__safety.7z" };
});
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalRoot === undefined) delete process.env.MYSQL_ROOT_PASSWORD;
else process.env.MYSQL_ROOT_PASSWORD = originalRoot;
await rm(stacksDir, { recursive: true, force: true });
});
function makeCtx() {
const steps: string[] = [];
const lines: string[] = [];
return {
steps, lines,
ctx: {
log: (l: string) => lines.push(l),
step: async <T,>(id: string, fn: () => Promise<T>) => (steps.push(`step:${id}`), fn()),
skip: (id: string) => steps.push(`skip:${id}`),
},
};
}
async function existingStack() {
await mkdir(path.join(stacksDir, "acme"), { recursive: true });
await writeFile(path.join(stacksDir, "acme", "compose.yaml"), "name: old\n");
await writeFile(path.join(stacksDir, "acme", ".env"), 'DOMAIN="acme.example.com"\nDATABASE_NAME="acme"\n');
}
it("fresh server: runs the steps in the planned order and the side effects in the planned order", async () => {
const { ctx, steps } = makeCtx();
const result = await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
expect(steps).toEqual([
"step:verify", "step:check", "skip:safety", "skip:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
]);
// The declared step list (what the UI renders) matches what actually runs.
expect(RESTORE_STEPS.map((s) => `${s.id}`)).toEqual(["verify", "check", "safety", "stop", "database", "import", "config", "media", "start", "health"]);
expect(calls).toEqual([
"7zz-test", "read:manifest.json", "read:config/.env",
"assertDomainAvailable", "listImageMigrations",
"recreateDatabase",
"pipeline:db.sql",
"writeCaddy:acme:acme.example.com",
"composeCreate", "pipeline:media.tar",
"composeUp",
"waitForHealth:true",
]);
expect(result).toEqual({ slug: "acme", domain: "acme.example.com", safetyBackup: null });
});
it("recreates the DB with the password FROM THE BACKED-UP .env, and writes that .env verbatim (0600) plus a regenerated compose.yaml", async () => {
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
expect(recreateDatabaseMock).toHaveBeenCalledWith("acme", "acme", DB_PASSWORD);
const envPath = path.join(stacksDir, "acme", ".env");
expect(await readFile(envPath, "utf8")).toBe(BACKED_UP_ENV);
expect((await stat(envPath)).mode & 0o777).toBe(0o600);
const compose = await readFile(path.join(stacksDir, "acme", "compose.yaml"), "utf8");
expect(compose).toContain("container_name: next-chair-acme");
expect(compose).toContain("next-chair-media-acme:/app/storage/media");
});
it("builds the import and media pipelines as argument arrays: 7zz -so → docker, no password in argv", async () => {
await performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx });
const [dumpSource, dumpSink] = runPipelineMock.mock.calls[0] as [CommandSpec, CommandSpec];
expect(dumpSource.command).toBe("7zz");
expect(dumpSource.args).toEqual(["x", "-so", "-t7z", "-y", "-bd", "-bso0", "-bsp0", ARCHIVE, "db.sql"]);
expect(dumpSink).toMatchObject({ command: "docker" });
expect(dumpSink.args).toEqual(["exec", "-i", "-e", "MYSQL_PWD", "next-chair-mysql", "mysql", "-uroot", "--default-character-set=utf8mb4", "acme"]);
expect(dumpSink.args.join(" ")).not.toContain("root-pw");
const [mediaSource, mediaSink] = runPipelineMock.mock.calls[1] as [CommandSpec, CommandSpec];
expect(mediaSource.args.at(-1)).toBe("media.tar");
expect(mediaSink.args).toContain("--user");
expect(mediaSink.args.at(-1)).toContain("chown -R 1001:1001");
});
it("existing business + replace: safety backup FIRST, then compose down (with volumes), then everything else", async () => {
await existingStack();
const { ctx, steps } = makeCtx();
const result = await performRestore({ archivePath: ARCHIVE, replace: true, ctx });
expect(steps).toEqual([
"step:verify", "step:check", "step:safety", "step:stop", "step:database", "step:import", "step:config", "step:media", "step:start", "step:health",
]);
const safety = calls.indexOf("safetyBackup:acme:safety");
const down = calls.findIndex((c) => c.startsWith("composeDown"));
expect(safety).toBeGreaterThan(-1);
expect(down).toBeGreaterThan(safety);
expect(calls[down]).toBe('composeDown:{"removeVolumes":true}');
expect(calls.indexOf("recreateDatabase")).toBeGreaterThan(down);
expect(result.safetyBackup).toBe("acme__20261017T040000Z__safety.7z");
});
it("existing business without 'replace': refuses before touching anything", async () => {
await existingStack();
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already exists.*replace/);
expect(performBackupMock).not.toHaveBeenCalled();
expect(recreateDatabaseMock).not.toHaveBeenCalled();
expect(calls.some((c) => c.startsWith("composeDown"))).toBe(false);
});
it("domain conflict: refuses before any destructive step", async () => {
assertDomainAvailableMock.mockRejectedValue(new Error('Domain "acme.example.com" is already in use by "other".'));
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/already in use/);
expect(recreateDatabaseMock).not.toHaveBeenCalled();
expect(runPipelineMock).not.toHaveBeenCalled();
});
it("version guard: a backup from a newer schema than the image is refused before any change", async () => {
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV,
);
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/newer than the running app image/);
expect(recreateDatabaseMock).not.toHaveBeenCalled();
});
it("refuses an archive whose .env disagrees with its manifest", async () => {
sevenZipReadMemberMock.mockImplementation(async (_a: string, member: string) =>
member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'),
);
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
expect(recreateDatabaseMock).not.toHaveBeenCalled();
});
it("a bad or corrupt archive fails in the first step", async () => {
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Data error"));
await expect(performRestore({ archivePath: ARCHIVE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Data error/);
expect(assertDomainAvailableMock).not.toHaveBeenCalled();
});
it("never logs any .env secret", async () => {
const { ctx, lines } = makeCtx();
await performRestore({ archivePath: ARCHIVE, replace: false, ctx });
const all = lines.join("\n");
expect(all).not.toContain(DB_PASSWORD);
expect(all).not.toContain("s3cret-from-backup");
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it, vi } from "vitest";
import { MemoryStateStore, runTick, taskKey, type SchedulerConfig, type SchedulerTask } from "../scheduler";
const config: SchedulerConfig = { notificationsIntervalSeconds: 30, healthIntervalMinutes: 3 };
function harness(tasks: SchedulerTask[], slugs: string[] = ["acme", "zeta"]) {
let nowMs = Date.parse("2026-10-17T12:00:00Z");
const store = new MemoryStateStore();
const inFlight = new Set<string>();
const tick = () =>
runTick({ tasks, store, listSlugs: async () => slugs, config: async () => config, now: () => new Date(nowMs), inFlight });
return { store, tick, advance: (ms: number) => (nowMs += ms), inFlight };
}
function task(overrides: Partial<SchedulerTask> & { run?: SchedulerTask["run"] } = {}): SchedulerTask & { calls: (string | null)[] } {
const calls: (string | null)[] = [];
return {
id: "notifications",
label: "n",
scope: "business",
intervalMs: (c) => c.notificationsIntervalSeconds * 1000,
run: async ({ slug }) => {
calls.push(slug);
return { ok: true, message: "HTTP 200" };
},
...overrides,
calls,
} as SchedulerTask & { calls: (string | null)[] };
}
describe("scheduler due-time", () => {
it("runs a per-business task for every business on the first tick", async () => {
const t = task();
const h = harness([t]);
const { started, done } = await h.tick();
await done;
expect(started.sort()).toEqual(["notifications:acme", "notifications:zeta"]);
expect(t.calls.sort()).toEqual(["acme", "zeta"]);
});
it("does not run again until the task's interval has elapsed (30s default for notifications)", async () => {
const t = task();
const h = harness([t], ["acme"]);
await (await h.tick()).done;
h.advance(29_000);
await (await h.tick()).done;
expect(t.calls).toHaveLength(1);
h.advance(1_000);
await (await h.tick()).done;
expect(t.calls).toHaveLength(2);
});
it("uses the configured interval, per task", async () => {
const fast = task({ id: "fast", intervalMs: () => 10_000 });
const slow = task({ id: "slow", intervalMs: () => 60_000 });
const h = harness([fast, slow], ["acme"]);
await (await h.tick()).done;
h.advance(10_000);
await (await h.tick()).done;
expect(fast.calls).toHaveLength(2);
expect(slow.calls).toHaveLength(1);
});
it("a once-a-day task survives a restart: persisted lastStartedAt is honoured", async () => {
const daily = task({ id: "reviews", intervalMs: () => 24 * 3600 * 1000 });
const h = harness([daily], ["acme"]);
h.store.patch(taskKey("reviews", "acme"), { lastStartedAt: new Date(Date.parse("2026-10-17T12:00:00Z") - 3600_000).toISOString() });
await (await h.tick()).done;
expect(daily.calls).toHaveLength(0);
h.advance(23 * 3600 * 1000);
await (await h.tick()).done;
expect(daily.calls).toHaveLength(1);
});
it("a custom isDue (scheduled backups) overrides the interval", async () => {
const isDue = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const backups = task({ id: "backups", isDue });
const h = harness([backups], ["acme"]);
await (await h.tick()).done;
expect(backups.calls).toHaveLength(0);
h.advance(5_000);
await (await h.tick()).done;
expect(backups.calls).toEqual(["acme"]);
});
it("global tasks run once, not once per business", async () => {
const g = task({ id: "maintenance", scope: "global", intervalMs: () => 3600_000 });
const h = harness([g]);
await (await h.tick()).done;
expect(g.calls).toEqual([null]);
});
});
describe("scheduler overlap guard and results", () => {
it("never starts a task/business pair that is still in flight, however many ticks pass", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const slow = task({
run: async ({ slug }) => {
(slow as unknown as { calls: (string | null)[] }).calls.push(slug);
await gate;
return { ok: true, message: "done" };
},
});
const h = harness([slow], ["acme", "zeta"]);
const first = await h.tick();
expect(first.started).toHaveLength(2);
// Plenty of time passes while both are still running...
h.advance(10 * 60_000);
const second = await h.tick();
expect(second.started).toHaveLength(0);
expect(h.inFlight.size).toBe(2);
release();
await first.done;
expect(h.inFlight.size).toBe(0);
h.advance(60_000);
expect((await h.tick()).started).toHaveLength(2);
});
it("one slow business doesn't block another: guards are per business", async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const calls: string[] = [];
const t = task({
run: async ({ slug }) => {
calls.push(slug!);
if (slug === "acme") await gate;
return { ok: true, message: "ok" };
},
});
const h = harness([t], ["acme", "zeta"]);
await h.tick();
h.advance(30_000);
const second = await h.tick();
expect(second.started).toEqual(["notifications:zeta"]);
release();
await second.done;
});
it("records the last result, including a thrown error, and keeps running other tasks", async () => {
const boom = task({ id: "boom", run: async () => { throw new Error("kaput"); } });
const fine = task({ id: "fine" });
const h = harness([boom, fine], ["acme"]);
await (await h.tick()).done;
const tasks = h.store.get().tasks;
expect(tasks["boom:acme"]).toMatchObject({ lastOk: false, lastMessage: "kaput" });
expect(tasks["fine:acme"]).toMatchObject({ lastOk: true, lastMessage: "HTTP 200" });
expect(h.store.get().heartbeatAt).not.toBeNull();
});
it("forgets state for businesses that no longer exist", async () => {
const t = task();
const h = harness([t], ["acme"]);
h.store.patch("notifications:gone", { lastStartedAt: "2026-01-01T00:00:00Z" });
await (await h.tick()).done;
expect(h.store.get().tasks["notifications:gone"]).toBeUndefined();
expect(h.store.get().tasks["notifications:acme"]).toBeDefined();
});
});
@@ -0,0 +1,102 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
const reloadCaddyMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: (...args: unknown[]) => reloadCaddyMock(...args),
runMigrations: vi.fn(),
}));
/**
* Regression coverage for a real production bug: a business behind a
* reverse proxy redirected every post-sign-in navigation to
* http://localhost:3000 instead of its own domain, because AUTH_URL was
* never set and Auth.js's header-based host detection didn't behave as
* expected in that deployment. The fix makes AUTH_URL explicit at
* provisioning time (see compose-template.test.ts) — this file covers
* the other place a business's domain gets set: editing it later, where
* AUTH_URL must keep tracking DOMAIN exactly like NEXT_PUBLIC_APP_URL
* already did, or a business that changes domains would silently regress
* back into the same bug.
*/
describe("updateBusiness — AUTH_URL tracks domain changes", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
const originalMysqlHost = process.env.MYSQL_HOST;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-updatebiz-test-"));
process.env.STACKS_DIR = stacksDir;
// updateBusiness's own domain-availability check just scans other
// stacks' .env files via listBusinessStacks() — no real MySQL needed
// for this test since we never change the domain to something
// already in use.
composeUpMock.mockClear();
reloadCaddyMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
if (originalMysqlHost === undefined) delete process.env.MYSQL_HOST;
else process.env.MYSQL_HOST = originalMysqlHost;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("updates AUTH_URL to the new domain, alongside DOMAIN and NEXT_PUBLIC_APP_URL", async () => {
const { updateBusiness } = await import("../provisioning");
await createStack(
"acme",
[
'BUSINESS_NAME="Acme"',
'DOMAIN="old-domain.example.com"',
'AUTH_URL="https://old-domain.example.com"',
'NEXT_PUBLIC_APP_URL="https://old-domain.example.com"',
'AUTH_SECRET="unrelated-secret"',
].join("\n") + "\n",
);
const result = await updateBusiness({ slug: "acme", name: "Acme", domain: "new-domain.example.com" });
expect(result.restarted).toBe(true);
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain('DOMAIN="new-domain.example.com"');
expect(envAfter).toContain('AUTH_URL="https://new-domain.example.com"');
expect(envAfter).toContain('NEXT_PUBLIC_APP_URL="https://new-domain.example.com"');
expect(envAfter).not.toContain("old-domain.example.com");
// Unrelated secrets untouched.
expect(envAfter).toContain('AUTH_SECRET="unrelated-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("leaves AUTH_URL alone (no restart) when only the business name changes, not the domain", async () => {
const { updateBusiness } = await import("../provisioning");
await createStack(
"acme",
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'AUTH_URL="https://acme.example.com"'].join("\n") + "\n",
);
const result = await updateBusiness({ slug: "acme", name: "Acme Barbers Ltd", domain: "acme.example.com" });
expect(result.restarted).toBe(false);
expect(composeUpMock).not.toHaveBeenCalled();
const envAfter = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(envAfter).toContain('AUTH_URL="https://acme.example.com"');
expect(envAfter).toContain('BUSINESS_NAME="Acme Barbers Ltd"');
});
});
+30
View File
@@ -0,0 +1,30 @@
import bcrypt from "bcryptjs";
/**
* A single, dedicated platform-operator credential via env vars — not
* tied to any one business's own Super Administrator (each business has
* its own separate database; this tool has no "primary" one of its own,
* so there's no single business account it could sensibly check against).
* Bcrypt cost 12 and the dummy-hash timing-safe comparison mirror the
* main app's own convention (see scripts/ensure-super-admin.ts and
* src/auth.ts) — copied, not imported: this app never imports from the
* main app's src/ at all (see session.ts's header for why).
*/
// A real bcrypt hash of an unguessable value, used only so a request for
// a wrong/missing MANAGER_EMAIL still pays the same bcrypt.compare cost
// as a wrong password — otherwise "no such email" and "wrong password"
// are distinguishable by response time.
const DUMMY_HASH = bcrypt.hashSync("nextchair-manager-dummy-hash", 12);
export async function verifyManagerCredentials(email: string, password: string): Promise<boolean> {
const expectedEmail = process.env.MANAGER_EMAIL?.trim().toLowerCase();
const expectedHash = process.env.MANAGER_PASSWORD_HASH;
if (!expectedEmail || !expectedHash) {
throw new Error("MANAGER_EMAIL and MANAGER_PASSWORD_HASH must both be set.");
}
const matchesEmail = email.trim().toLowerCase() === expectedEmail;
const valid = await bcrypt.compare(password, matchesEmail ? expectedHash : DUMMY_HASH);
return matchesEmail && valid;
}
+147
View File
@@ -0,0 +1,147 @@
import { isValidSlug } from "./slug";
/**
* The manifest.json at the root of every backup archive — what a restore
* (possibly on a brand-new server that has never seen this business)
* learns about the backup before touching anything.
*/
export const BACKUP_FORMAT_VERSION = 1;
export const BACKUP_MEMBERS = {
manifest: "manifest.json",
database: "db.sql",
media: "media.tar",
env: "config/.env",
compose: "config/compose.yaml",
caddy: "config/site.caddy",
} as const;
export type BackupKind = "regular" | "safety" | "final";
export const BACKUP_KINDS: readonly BackupKind[] = ["regular", "safety", "final"];
export interface BackupManifest {
formatVersion: number;
slug: string;
name: string | null;
domain: string;
databaseName: string;
databaseUser: string;
createdAt: string;
kind: BackupKind;
/** Newest applied Prisma migration in the dumped database; null if it couldn't be read. */
lastMigration: string | null;
appImage: string | null;
hasCaddySite: boolean;
}
export class ManifestError extends Error {
constructor(message: string) {
super(message);
this.name = "ManifestError";
}
}
const SAFE_DB_IDENTIFIER = /^[a-z][a-z0-9_]{1,48}[a-z0-9]$/;
const MIGRATION_NAME = /^\d{14}_[A-Za-z0-9_]+$/;
function requireString(obj: Record<string, unknown>, key: string): string {
const value = obj[key];
if (typeof value !== "string" || !value.trim()) throw new ManifestError(`manifest.json is missing "${key}".`);
return value;
}
export function buildManifest(input: Omit<BackupManifest, "formatVersion">): BackupManifest {
return { formatVersion: BACKUP_FORMAT_VERSION, ...input };
}
/** Strict parse — a restore trusts nothing from the archive: slug, DB names and domain all end up in file paths or SQL. */
export function parseManifest(raw: string): BackupManifest {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new ManifestError("manifest.json isn't valid JSON — this doesn't look like a NextChair backup.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ManifestError("manifest.json has an unexpected shape.");
const obj = parsed as Record<string, unknown>;
const formatVersion = obj.formatVersion;
if (typeof formatVersion !== "number" || !Number.isInteger(formatVersion) || formatVersion < 1) {
throw new ManifestError("manifest.json has no valid formatVersion.");
}
if (formatVersion > BACKUP_FORMAT_VERSION) {
throw new ManifestError(
`This backup uses format v${formatVersion}, newer than this Manager understands (v${BACKUP_FORMAT_VERSION}). Update the Manager first.`,
);
}
const slug = requireString(obj, "slug");
if (!isValidSlug(slug)) throw new ManifestError(`The backup's slug "${slug}" isn't valid.`);
const domain = requireString(obj, "domain").trim().toLowerCase();
if (/["\s]/.test(domain)) throw new ManifestError("The backup's domain isn't valid.");
const databaseName = requireString(obj, "databaseName");
const databaseUser = requireString(obj, "databaseUser");
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
throw new ManifestError("The backup names an unsafe database or user identifier.");
}
const createdAt = requireString(obj, "createdAt");
if (Number.isNaN(Date.parse(createdAt))) throw new ManifestError("The backup's createdAt isn't a valid date.");
const kind = obj.kind;
if (kind !== "regular" && kind !== "safety" && kind !== "final") throw new ManifestError("The backup has an unknown kind.");
const lastMigration = obj.lastMigration;
if (lastMigration !== null && lastMigration !== undefined) {
if (typeof lastMigration !== "string" || !MIGRATION_NAME.test(lastMigration)) throw new ManifestError("The backup's lastMigration isn't valid.");
}
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name : null;
if (name && (name.includes('"') || name.includes("\n") || name.includes("\r"))) throw new ManifestError("The backup's business name isn't valid.");
return {
formatVersion,
slug,
name,
domain,
databaseName,
databaseUser,
createdAt,
kind,
lastMigration: typeof lastMigration === "string" ? lastMigration : null,
appImage: typeof obj.appImage === "string" ? obj.appImage : null,
hasCaddySite: obj.hasCaddySite === true,
};
}
/** Migration folders start with a 14-digit timestamp, so the prefix orders them. */
function migrationStamp(name: string): string {
return name.slice(0, 14);
}
/**
* Version guard: a dump taken on a newer schema than the running image
* knows would boot an app that can't read its own database. Refuse when
* the backup's last migration is newer than the image's newest. (An older
* dump is fine — the container's entrypoint runs `prisma migrate deploy`.)
*/
export function checkMigrationCompatibility(
backupLastMigration: string | null,
imageMigrations: string[],
): { ok: true } | { ok: false; reason: string } {
if (!backupLastMigration) return { ok: true };
if (imageMigrations.length === 0) {
return { ok: false, reason: "Couldn't read the migration list from the app image, so the schema version can't be checked. Is the app image built?" };
}
const newestInImage = [...imageMigrations].sort().at(-1)!;
if (migrationStamp(backupLastMigration) > migrationStamp(newestInImage)) {
return {
ok: false,
reason: `This backup's database is on migration "${backupLastMigration}", newer than the running app image's newest ("${newestInImage}"). Rebuild/update the app image first, then restore.`,
};
}
return { ok: true };
}
+173
View File
@@ -0,0 +1,173 @@
import { DEFAULT_KEEP, MAX_KEEP } from "./backup-store";
import { readJsonFile, writeJsonFileAtomic } from "./json-file";
import { backupSettingsPath } from "./manager-paths";
import { withMutex } from "./singleton";
/**
* Per-business backup schedule, in one manager-side file keyed by slug
* (STACKS_DIR/manager/backup-settings.json) — never inside a business's
* own stack directory, so it survives that stack being deleted and
* restored.
*
* Times are the Manager container's LOCAL time (set TZ on the container
* to change it; the default is UTC).
*/
export type BackupFrequency = "daily" | "weekly";
export interface BackupSettings {
enabled: boolean;
frequency: BackupFrequency;
/** "HH:MM", 24h, Manager-local. */
time: string;
/** 0 = Sunday … 6 = Saturday; only used when weekly. */
weekday: number;
/** How many regular backups to keep (newest first). */
keep: number;
enabledAt: string | null;
lastAttemptAt: string | null;
lastSuccessAt: string | null;
lastStatus: "ok" | "failed" | null;
lastMessage: string | null;
}
export const DEFAULT_BACKUP_SETTINGS: BackupSettings = {
enabled: false,
frequency: "daily",
time: "03:00",
weekday: 0,
keep: DEFAULT_KEEP,
enabledAt: null,
lastAttemptAt: null,
lastSuccessAt: null,
lastStatus: null,
lastMessage: null,
};
interface BackupSettingsFile {
businesses: Record<string, Partial<BackupSettings>>;
}
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
export function isValidBackupTime(value: string): boolean {
return TIME_PATTERN.test(value);
}
function normalize(raw: Partial<BackupSettings> | undefined): BackupSettings {
const merged = { ...DEFAULT_BACKUP_SETTINGS, ...(raw ?? {}) };
return {
...merged,
frequency: merged.frequency === "weekly" ? "weekly" : "daily",
time: isValidBackupTime(merged.time) ? merged.time : DEFAULT_BACKUP_SETTINGS.time,
weekday: Number.isInteger(merged.weekday) && merged.weekday >= 0 && merged.weekday <= 6 ? merged.weekday : 0,
keep: Number.isInteger(merged.keep) ? Math.min(MAX_KEEP, Math.max(1, merged.keep)) : DEFAULT_KEEP,
};
}
async function readFile(): Promise<BackupSettingsFile> {
const file = await readJsonFile<BackupSettingsFile>(backupSettingsPath(), { businesses: {} });
return { businesses: file.businesses ?? {} };
}
export async function readBackupSettings(slug: string): Promise<BackupSettings> {
return normalize((await readFile()).businesses[slug]);
}
export async function readAllBackupSettings(): Promise<Record<string, BackupSettings>> {
const file = await readFile();
return Object.fromEntries(Object.entries(file.businesses).map(([slug, raw]) => [slug, normalize(raw)]));
}
export interface BackupSettingsInput {
enabled: boolean;
frequency: BackupFrequency;
time: string;
weekday: number;
keep: number;
}
export function validateBackupSettingsInput(input: BackupSettingsInput): string | null {
if (!isValidBackupTime(input.time)) return "Enter the time as HH:MM (24-hour).";
if (input.frequency !== "daily" && input.frequency !== "weekly") return "Choose daily or weekly.";
if (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6) return "Choose a day of the week.";
if (!Number.isInteger(input.keep) || input.keep < 1 || input.keep > MAX_KEEP) return `Keep between 1 and ${MAX_KEEP} backups.`;
return null;
}
export function saveBackupSettings(slug: string, input: BackupSettingsInput, now: Date = new Date()): Promise<BackupSettings> {
return withMutex("backup-settings", async () => {
const file = await readFile();
const current = normalize(file.businesses[slug]);
const next: BackupSettings = {
...current,
enabled: input.enabled,
frequency: input.frequency,
time: input.time,
weekday: input.weekday,
keep: input.keep,
// The first scheduled run waits for the next scheduled time after enabling, rather than firing the moment the box is ticked ("Back up now" exists for that).
enabledAt: input.enabled && !current.enabled ? now.toISOString() : current.enabledAt,
};
file.businesses[slug] = next;
await writeJsonFileAtomic(backupSettingsPath(), file);
return next;
});
}
export function recordBackupOutcome(
slug: string,
outcome: { ok: boolean; message: string; at?: Date },
): Promise<void> {
return withMutex("backup-settings", async () => {
const file = await readFile();
const current = normalize(file.businesses[slug]);
const at = (outcome.at ?? new Date()).toISOString();
file.businesses[slug] = {
...current,
lastAttemptAt: at,
lastSuccessAt: outcome.ok ? at : current.lastSuccessAt,
lastStatus: outcome.ok ? "ok" : "failed",
lastMessage: outcome.message.slice(0, 500),
};
await writeJsonFileAtomic(backupSettingsPath(), file);
});
}
/** Marks that a scheduled run was STARTED (so a failing backup retries at the next scheduled time, not every scheduler tick). */
export function recordBackupAttempt(slug: string, at: Date = new Date()): Promise<void> {
return withMutex("backup-settings", async () => {
const file = await readFile();
file.businesses[slug] = { ...normalize(file.businesses[slug]), lastAttemptAt: at.toISOString() };
await writeJsonFileAtomic(backupSettingsPath(), file);
});
}
/** The most recent scheduled moment at or before `now` (Manager-local time). */
export function lastScheduledOccurrence(settings: Pick<BackupSettings, "frequency" | "time" | "weekday">, now: Date): Date {
const [hours, minutes] = settings.time.split(":").map(Number) as [number, number];
const candidate = new Date(now);
candidate.setHours(hours, minutes, 0, 0);
if (candidate.getTime() > now.getTime()) candidate.setDate(candidate.getDate() - 1);
if (settings.frequency === "weekly") {
while (candidate.getDay() !== settings.weekday) candidate.setDate(candidate.getDate() - 1);
}
return candidate;
}
/**
* Due when a scheduled moment has passed since the later of "last
* attempt" and "when it was enabled". Keyed on attempts, not successes:
* a backup that fails (disk full, MySQL down) is retried at the NEXT
* scheduled time and alerted on, instead of being re-run every few
* seconds all day.
*/
export function isBackupDue(settings: BackupSettings, now: Date): boolean {
if (!settings.enabled) return false;
const occurrence = lastScheduledOccurrence(settings, now);
const since = Math.max(
settings.lastAttemptAt ? Date.parse(settings.lastAttemptAt) : 0,
settings.enabledAt ? Date.parse(settings.enabledAt) : 0,
);
return occurrence.getTime() > since;
}
Loaded 100 of 403 files, more files were not shown because too many files have changed in this diff. Show more