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.
This commit is contained in:
95 files changed
+7955
-150
No files matched your search
@@ -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.
|
||||||
@@ -158,7 +158,7 @@ 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 has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward.
|
||||||
- **Owner-editable breaks/whole-day time off for a staff member were not built** — only partial-day "Block Time" (via `OwnerBlockTimeButton`) satisfies "add time off on behalf of staff" this phase; a staff member's own recurring breaks and whole-day time off can currently only be edited by that staff member themselves.
|
- **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
|
||||||
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
|
- **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" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists.
|
||||||
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
|
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
|
||||||
@@ -166,7 +166,7 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
|
|||||||
|
|
||||||
## 20. Review items for Phase 7
|
## 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.
|
||||||
|
|||||||
@@ -66,25 +66,33 @@ 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);
|
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 }) => {
|
||||||
@@ -120,11 +128,11 @@ test.describe("customer booking journeys", () => {
|
|||||||
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();
|
||||||
@@ -134,12 +142,13 @@ 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 }) => {
|
||||||
@@ -147,8 +156,7 @@ test.describe("customer booking journeys", () => {
|
|||||||
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.
|
||||||
@@ -161,8 +169,6 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,19 @@ STACKS_DIR="/opt/nextchair/stacks"
|
|||||||
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
|
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
|
||||||
# up.
|
# up.
|
||||||
NEXTCHAIR_IMAGE="nextchair-app:latest"
|
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"
|
||||||
@@ -30,6 +30,11 @@ ENV NODE_ENV=production
|
|||||||
# stacks/infra/compose.yaml's manager service (docker.sock).
|
# stacks/infra/compose.yaml's manager service (docker.sock).
|
||||||
RUN apk add --no-cache docker-cli docker-cli-compose
|
RUN apk add --no-cache docker-cli docker-cli-compose
|
||||||
|
|
||||||
|
# 7-Zip for encrypted backups (`7zz a -mhe=on -p…`, 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
|
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/node_modules ./node_modules
|
||||||
|
|||||||
+49
-2
@@ -30,12 +30,59 @@ copied and adapted, not imported — see `src/lib/auth.ts` and
|
|||||||
reloads Caddy, starts the container, and runs its migrations. See
|
reloads Caddy, starts the container, and runs its migrations. See
|
||||||
`src/lib/provisioning.ts` for the exact sequence.
|
`src/lib/provisioning.ts` for the exact sequence.
|
||||||
- Per business: start / stop / restart / update (recreate the container
|
- Per business: start / stop / restart / update (recreate the container
|
||||||
against the current shared image tag — not a source rebuild) / view
|
against the current shared image tag — not a source rebuild, and run as
|
||||||
recent logs.
|
a background job with live progress and an optional "back up first") /
|
||||||
|
view recent logs.
|
||||||
- Does **not** duplicate the `/install` wizard — after provisioning, the
|
- Does **not** duplicate the `/install` wizard — after provisioning, the
|
||||||
operator is linked to `https://<domain>/install` to finish that
|
operator is linked to `https://<domain>/install` to finish that
|
||||||
business's own setup.
|
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): an encrypted `.7z`
|
||||||
|
(`7zz`, AES-256, encrypted headers) 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. One
|
||||||
|
passphrase protects everything: generated at the first enable / first
|
||||||
|
*Back up now*, stored encrypted (`MANAGER_SETTINGS_ENCRYPTION_KEY`),
|
||||||
|
**shown once** — save it, a fresh server needs it to restore. 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
|
## How auth works
|
||||||
|
|
||||||
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
|
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
|
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/session";
|
||||||
import proxy from "../proxy";
|
import proxy, { config } from "../proxy";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installation-token panel (and its regenerate action) lives under
|
* The installation-token panel (and its regenerate action) lives under
|
||||||
@@ -64,3 +64,34 @@ describe("proxy — Manager auth gate", () => {
|
|||||||
expect(response.status).toBe(200);
|
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 encrypted; the download itself exposes nothing without the passphrase. */
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 (the restore passphrase only ever
|
||||||
|
* lives in the running job's closure).
|
||||||
|
*/
|
||||||
|
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" } });
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import path from "node:path";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
|
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
|
||||||
import { upsertEnvValues } from "@/lib/env-file";
|
import { upsertEnvValues } from "@/lib/env-file";
|
||||||
|
import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
|
||||||
|
import { setIntentionallyStopped } from "@/lib/health-watcher";
|
||||||
import { fetchInstallationStatus } from "@/lib/installation-status";
|
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 { randomHex } from "@/lib/random";
|
||||||
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
|
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
|
||||||
|
|
||||||
@@ -17,27 +22,47 @@ async function assertStack(slug: string): Promise<string> {
|
|||||||
|
|
||||||
export async function startStackAction(slug: string): Promise<void> {
|
export async function startStackAction(slug: string): Promise<void> {
|
||||||
const dir = await assertStack(slug);
|
const dir = await assertStack(slug);
|
||||||
|
await assertNotLocked(slug);
|
||||||
await composeUp(dir);
|
await composeUp(dir);
|
||||||
|
await setIntentionallyStopped(slug, false);
|
||||||
revalidatePath(`/dashboard/${slug}`);
|
revalidatePath(`/dashboard/${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopStackAction(slug: string): Promise<void> {
|
export async function stopStackAction(slug: string): Promise<void> {
|
||||||
const dir = await assertStack(slug);
|
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);
|
await composeDown(dir);
|
||||||
revalidatePath(`/dashboard/${slug}`);
|
revalidatePath(`/dashboard/${slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartStackAction(slug: string): Promise<void> {
|
export async function restartStackAction(slug: string): Promise<void> {
|
||||||
const dir = await assertStack(slug);
|
const dir = await assertStack(slug);
|
||||||
|
await assertNotLocked(slug);
|
||||||
await composeRestart(dir);
|
await composeRestart(dir);
|
||||||
revalidatePath(`/dashboard/${slug}`);
|
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. */
|
/**
|
||||||
export async function updateStackAction(slug: string): Promise<void> {
|
* Recreates the container against whatever the shared image tag currently
|
||||||
const dir = await assertStack(slug);
|
* points to (--force-recreate) — not a source rebuild. See
|
||||||
await composeUp(dir, { forceRecreate: true });
|
* manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild
|
||||||
revalidatePath(`/dashboard/${slug}`);
|
* 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 {
|
||||||
|
// "Back up first" needs a passphrase; if this is the very first backup anywhere, it is minted here and shown once.
|
||||||
|
const newPassphrase = backupFirst && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
|
||||||
|
const { jobId } = await startUpdateJob(slug, backupFirst);
|
||||||
|
return { error: null, jobId, newPassphrase };
|
||||||
|
} 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> {
|
export async function fetchLogsAction(slug: string): Promise<string> {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { saveBackupSettings, validateBackupSettingsInput, type BackupFrequency } from "@/lib/backup-settings";
|
||||||
|
import { deleteBackup } from "@/lib/backup-store";
|
||||||
|
import { ensureBackupPassphrase, hasBackupPassphrase, rotateBackupPassphrase } from "@/lib/global-settings";
|
||||||
|
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;
|
||||||
|
/** Plaintext passphrase — only in the one response that just generated it (first enable anywhere). */
|
||||||
|
newPassphrase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, newPassphrase: null };
|
||||||
|
|
||||||
|
// Generated at the first enable, encrypted with the Manager's key, shown once.
|
||||||
|
let newPassphrase: string | null = null;
|
||||||
|
if (input.enabled) {
|
||||||
|
const ensured = await ensureBackupPassphrase();
|
||||||
|
if (ensured.created) newPassphrase = ensured.passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBackupSettings(slug, input);
|
||||||
|
revalidatePath(`/dashboard/${slug}/backups`);
|
||||||
|
return { error: null, saved: true, newPassphrase };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupNowAction(slug: string): Promise<JobStartState> {
|
||||||
|
await assertBusiness(slug);
|
||||||
|
try {
|
||||||
|
const newPassphrase = (await hasBackupPassphrase()) ? null : (await ensureBackupPassphrase()).passphrase;
|
||||||
|
const { jobId } = await startBackupJob(slug, "regular");
|
||||||
|
return { error: null, jobId, newPassphrase };
|
||||||
|
} 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the passphrase used for NEW backups. Older archives still need the old one — the UI says so. Returns the new value once. */
|
||||||
|
export async function rotatePassphraseAction(): Promise<{ error: string | null; newPassphrase: string | null }> {
|
||||||
|
try {
|
||||||
|
return { error: null, newPassphrase: await rotateBackupPassphrase() };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: error instanceof Error ? error.message : "Couldn't generate a passphrase.", newPassphrase: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"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 { PassphraseNotice } from "../../passphrase-notice";
|
||||||
|
import {
|
||||||
|
backupNowAction,
|
||||||
|
deleteBackupAction,
|
||||||
|
rotatePassphraseAction,
|
||||||
|
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, newPassphrase: null };
|
||||||
|
|
||||||
|
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,
|
||||||
|
hasPassphrase,
|
||||||
|
passphraseCreatedAt,
|
||||||
|
activeJobId,
|
||||||
|
timeZone,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
settings: SettingsView;
|
||||||
|
backups: BackupEntry[];
|
||||||
|
hasPassphrase: boolean;
|
||||||
|
passphraseCreatedAt: string | null;
|
||||||
|
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 [shownPassphrase, setShownPassphrase] = useState<string | null>(null);
|
||||||
|
const passphraseToShow = shownPassphrase ?? saveState.newPassphrase;
|
||||||
|
|
||||||
|
const backupNow = () => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await backupNowAction(slug);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.newPassphrase) setShownPassphrase(result.newPassphrase);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotate = () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Generate a NEW passphrase? New backups will use it. Existing backups keep the old one — you will need both to open all of them.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await rotatePassphraseAction();
|
||||||
|
if (result.error) setError(result.error);
|
||||||
|
else setShownPassphrase(result.newPassphrase);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{passphraseToShow && <PassphraseNotice passphrase={passphraseToShow} />}
|
||||||
|
|
||||||
|
<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">Passphrase</h2>
|
||||||
|
<p>
|
||||||
|
{hasPassphrase
|
||||||
|
? `A passphrase protects every backup${passphraseCreatedAt ? ` (created ${new Date(passphraseCreatedAt).toLocaleDateString()})` : ""}. It is stored encrypted here and cannot be shown again — keep your saved copy; a fresh server needs it to restore.`
|
||||||
|
: "No passphrase yet — one is generated (and shown once) the first time you enable backups or press Back up now."}
|
||||||
|
</p>
|
||||||
|
{hasPassphrase && (
|
||||||
|
<button type="button" onClick={rotate} disabled={isPending} className="font-medium text-ink underline disabled:opacity-60">
|
||||||
|
Generate a new passphrase…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 { backupPassphraseCreatedAt, hasBackupPassphrase } from "@/lib/global-settings";
|
||||||
|
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, hasPassphrase, passphraseCreatedAt, lock] = await Promise.all([
|
||||||
|
readBackupSettings(slug),
|
||||||
|
listBackups(slug),
|
||||||
|
hasBackupPassphrase(),
|
||||||
|
backupPassphraseCreatedAt(),
|
||||||
|
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}
|
||||||
|
hasPassphrase={hasPassphrase}
|
||||||
|
passphraseCreatedAt={passphraseCreatedAt}
|
||||||
|
activeJobId={lock?.jobId ?? null}
|
||||||
|
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
|
import { JobProgress } from "../job-progress";
|
||||||
|
import { PassphraseNotice } from "../passphrase-notice";
|
||||||
import {
|
import {
|
||||||
fetchLogsAction,
|
fetchLogsAction,
|
||||||
restartStackAction,
|
restartStackAction,
|
||||||
@@ -9,10 +11,18 @@ import {
|
|||||||
updateStackAction,
|
updateStackAction,
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
|
|
||||||
export function BusinessActions({ slug }: { slug: string }) {
|
export function BusinessActions({ slug, activeJobId }: { slug: string; activeJobId: string | null }) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [logs, setLogs] = 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);
|
||||||
|
const [newPassphrase, setNewPassphrase] = useState<string | null>(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>) => {
|
const run = (action: (slug: string) => Promise<void>) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -25,6 +35,25 @@ export function BusinessActions({ slug }: { slug: string }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runUpdate = () => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateStackAction(slug, backupFirst);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewPassphrase(result.newPassphrase);
|
||||||
|
setJobId(result.jobId);
|
||||||
|
setJobRunning(true);
|
||||||
|
setUpdateOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Couldn't start the update.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadLogs = () => {
|
const loadLogs = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -41,7 +70,7 @@ export function BusinessActions({ slug }: { slug: string }) {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={busy}
|
||||||
onClick={() => run(startStackAction)}
|
onClick={() => run(startStackAction)}
|
||||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
>
|
>
|
||||||
@@ -49,7 +78,7 @@ export function BusinessActions({ slug }: { slug: string }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={busy}
|
||||||
onClick={() => run(stopStackAction)}
|
onClick={() => run(stopStackAction)}
|
||||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
>
|
>
|
||||||
@@ -57,7 +86,7 @@ export function BusinessActions({ slug }: { slug: string }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={busy}
|
||||||
onClick={() => run(restartStackAction)}
|
onClick={() => run(restartStackAction)}
|
||||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
|
||||||
>
|
>
|
||||||
@@ -65,8 +94,9 @@ export function BusinessActions({ slug }: { slug: string }) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isPending}
|
disabled={busy}
|
||||||
onClick={() => run(updateStackAction)}
|
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"
|
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."
|
title="Recreates the container against the current shared image tag — not a source rebuild."
|
||||||
>
|
>
|
||||||
@@ -81,11 +111,36 @@ export function BusinessActions({ slug }: { slug: string }) {
|
|||||||
View logs
|
View logs
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<p role="alert" className="text-sm font-medium text-danger">
|
<p role="alert" className="text-sm font-medium text-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
|
||||||
|
{jobId && <JobProgress key={jobId} jobId={jobId} onFinished={() => setJobRunning(false)} />}
|
||||||
{logs !== null && (
|
{logs !== null && (
|
||||||
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
|
<pre className="max-h-96 overflow-auto rounded-md bg-ink p-4 text-xs text-white">{logs}</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { redirect } from "next/navigation";
|
import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
|
||||||
import { deleteBusiness } from "@/lib/provisioning";
|
import { BusinessLockedError } from "@/lib/locks";
|
||||||
|
import { startDeleteJob } from "@/lib/operations";
|
||||||
|
|
||||||
export interface DeleteBusinessState {
|
export interface DeleteBusinessState {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
/** Set once the delete job has started — the form then follows it with <JobProgress>. */
|
||||||
|
jobId: string | null;
|
||||||
|
/** Plaintext backup passphrase, only in the response that just generated it. */
|
||||||
|
newPassphrase: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `slug` is bound in by the page, the same way updateBusinessAction takes
|
* `slug` is bound in by the page, the same way updateBusinessAction takes
|
||||||
* it — the operator only ever types a *confirmation* string, never the
|
* it — the operator only ever types a *confirmation* string, never the
|
||||||
* slug being acted on as a submittable field.
|
* 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(
|
export async function deleteBusinessAction(
|
||||||
slug: string,
|
slug: string,
|
||||||
@@ -19,17 +28,16 @@ export async function deleteBusinessAction(
|
|||||||
): Promise<DeleteBusinessState> {
|
): Promise<DeleteBusinessState> {
|
||||||
const confirmation = String(formData.get("confirmation") ?? "");
|
const confirmation = String(formData.get("confirmation") ?? "");
|
||||||
if (confirmation !== slug) {
|
if (confirmation !== slug) {
|
||||||
return { error: `Type "${slug}" exactly to confirm deletion.` };
|
return { error: `Type "${slug}" exactly to confirm deletion.`, jobId: null, newPassphrase: null };
|
||||||
}
|
}
|
||||||
|
const finalBackup = formData.get("finalBackup") === "on";
|
||||||
|
|
||||||
let result: { slug: string; warnings: string[] };
|
|
||||||
try {
|
try {
|
||||||
result = await deleteBusiness(slug);
|
const newPassphrase = finalBackup && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
|
||||||
|
const { jobId } = await startDeleteJob(slug, finalBackup);
|
||||||
|
return { error: null, jobId, newPassphrase };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error instanceof Error ? error.message : "Failed to delete this business." };
|
if (error instanceof BusinessLockedError) return { error: error.message, jobId: null, newPassphrase: null };
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to delete this business.", jobId: null, newPassphrase: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({ justDeleted: result.slug });
|
|
||||||
if (result.warnings.length > 0) params.set("warnings", result.warnings.join("; "));
|
|
||||||
redirect(`/dashboard?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
|
import { JobProgress } from "../../job-progress";
|
||||||
|
import { PassphraseNotice } from "../../passphrase-notice";
|
||||||
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
|
import { deleteBusinessAction, type DeleteBusinessState } from "./actions";
|
||||||
|
|
||||||
const initialState: DeleteBusinessState = { error: null };
|
const initialState: DeleteBusinessState = { error: null, jobId: null, newPassphrase: null };
|
||||||
|
|
||||||
export function DeleteBusinessForm({ slug }: { slug: string }) {
|
export function DeleteBusinessForm({ slug }: { slug: string }) {
|
||||||
const boundAction = deleteBusinessAction.bind(null, slug);
|
const boundAction = deleteBusinessAction.bind(null, slug);
|
||||||
@@ -11,8 +14,27 @@ export function DeleteBusinessForm({ slug }: { slug: string }) {
|
|||||||
const [confirmation, setConfirmation] = useState("");
|
const [confirmation, setConfirmation] = useState("");
|
||||||
const canDelete = confirmation === slug;
|
const canDelete = confirmation === slug;
|
||||||
|
|
||||||
|
if (state.jobId) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state.newPassphrase && <PassphraseNotice passphrase={state.newPassphrase} />}
|
||||||
|
<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 (
|
return (
|
||||||
<form action={formAction} className="space-y-4">
|
<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>
|
<div>
|
||||||
<label htmlFor="confirmation" className="block text-sm font-medium text-muted">
|
<label htmlFor="confirmation" className="block text-sm font-medium text-muted">
|
||||||
Type <span className="font-mono font-semibold text-ink">{slug}</span> to confirm
|
Type <span className="font-mono font-semibold text-ink">{slug}</span> to confirm
|
||||||
@@ -39,7 +61,7 @@ export function DeleteBusinessForm({ slug }: { slug: string }) {
|
|||||||
disabled={!canDelete || isPending}
|
disabled={!canDelete || isPending}
|
||||||
className="w-full rounded-md bg-danger py-2 text-sm font-semibold text-white disabled:opacity-40"
|
className="w-full rounded-md bg-danger py-2 text-sm font-semibold text-white disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{isPending ? "Deleting…" : "Delete Business"}
|
{isPending ? "Starting…" : "Delete Business"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { resolveInstallationView } from "@/lib/installation-status";
|
import { resolveInstallationView } from "@/lib/installation-status";
|
||||||
|
import { getActiveLock } from "@/lib/locks";
|
||||||
import { listBusinessStacks } from "@/lib/stacks";
|
import { listBusinessStacks } from "@/lib/stacks";
|
||||||
import { BusinessActions } from "./business-actions";
|
import { BusinessActions } from "./business-actions";
|
||||||
import { InstallationTokenPanel } from "./installation-token-panel";
|
import { InstallationTokenPanel } from "./installation-token-panel";
|
||||||
@@ -22,6 +23,8 @@ export default async function BusinessDetailPage({
|
|||||||
if (!stack) notFound();
|
if (!stack) notFound();
|
||||||
|
|
||||||
const installationView = await resolveInstallationView(slug, stack.domain);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -46,6 +49,12 @@ export default async function BusinessDetailPage({
|
|||||||
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
<p className="text-sm text-muted">{stack.domain ?? "no domain set"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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
|
<Link
|
||||||
href={`/dashboard/${stack.slug}/edit`}
|
href={`/dashboard/${stack.slug}/edit`}
|
||||||
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
|
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium"
|
||||||
@@ -61,7 +70,7 @@ export default async function BusinessDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BusinessActions slug={stack.slug} />
|
<BusinessActions slug={stack.slug} activeJobId={activeLock?.jobId ?? null} />
|
||||||
|
|
||||||
<InstallationTokenPanel slug={stack.slug} view={installationView} />
|
<InstallationTokenPanel slug={stack.slug} view={installationView} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
NextChair Manager
|
NextChair Manager
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<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">
|
<Link href="/dashboard/settings" className="text-muted hover:text-ink">
|
||||||
Settings
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
/** The backup passphrase, shown exactly once (in the response that generated it). Nothing here persists it — the server keeps only an encrypted copy. */
|
||||||
|
export function PassphraseNotice({ passphrase }: { passphrase: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(passphrase);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="alert" className="space-y-2 rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
|
||||||
|
<p className="font-semibold text-amber-900">Save this backup passphrase now — it won't be shown again.</p>
|
||||||
|
<p className="text-amber-900">
|
||||||
|
Every backup is encrypted with it. On a new server you'll need to type it to restore; without it the backups
|
||||||
|
cannot be opened by anyone.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<code className="select-all rounded bg-white px-3 py-1.5 font-mono text-base font-semibold">{passphrase}</code>
|
||||||
|
<button type="button" onClick={copy} className="rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium">
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ensureBackupPassphrase, hasBackupPassphrase, readBackupPassphrase } from "@/lib/global-settings";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function passphraseFor(input: string): Promise<string> {
|
||||||
|
const passphrase = input.trim() || (await readBackupPassphrase()) || "";
|
||||||
|
if (!passphrase) throw new Error("Enter the backup passphrase (this server has no stored one).");
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens only the encrypted header (fast) and reports what restoring would do — nothing is changed. A wrong passphrase fails here. */
|
||||||
|
export async function inspectRestoreAction(sourceKey: string, passphraseInput: string): Promise<InspectRestoreState> {
|
||||||
|
try {
|
||||||
|
const source = parseSourceKey(sourceKey);
|
||||||
|
const manifest = await readArchiveManifest(resolveArchivePath(source), await passphraseFor(passphraseInput));
|
||||||
|
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) {
|
||||||
|
// A wrong passphrase makes 7zz fail with a generic message; say so plainly instead of passing its output through.
|
||||||
|
const message = error instanceof Error ? error.message : "";
|
||||||
|
const looksLikePassphrase = /wrong password|can not open encrypted|data error|headers error|exited with code 2/i.test(message);
|
||||||
|
return {
|
||||||
|
error: looksLikePassphrase ? "Couldn't open the archive — wrong passphrase, or the file isn't a valid NextChair backup." : message || "Couldn't read that backup.",
|
||||||
|
preview: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRestoreState {
|
||||||
|
error: string | null;
|
||||||
|
jobId: string | null;
|
||||||
|
newPassphrase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRestoreAction(
|
||||||
|
sourceKey: string,
|
||||||
|
passphraseInput: string,
|
||||||
|
replace: boolean,
|
||||||
|
confirmSlug: string,
|
||||||
|
): Promise<StartRestoreState> {
|
||||||
|
const fail = (error: string): StartRestoreState => ({ error, jobId: null, newPassphrase: null });
|
||||||
|
try {
|
||||||
|
const source = parseSourceKey(sourceKey);
|
||||||
|
const passphrase = await passphraseFor(passphraseInput);
|
||||||
|
const manifest = await readArchiveManifest(resolveArchivePath(source), passphrase);
|
||||||
|
|
||||||
|
// 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.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replacing takes a safety backup, which needs a stored passphrase; mint one (shown once) if this server has none yet.
|
||||||
|
const newPassphrase = plan.slugExists && !(await hasBackupPassphrase()) ? (await ensureBackupPassphrase()).passphrase : null;
|
||||||
|
const { jobId } = await startRestoreJob({ source, passphrase, replace });
|
||||||
|
return { error: null, jobId, newPassphrase };
|
||||||
|
} 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,45 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { listAllBackups } from "@/lib/backup-store";
|
||||||
|
import { hasBackupPassphrase } from "@/lib/global-settings";
|
||||||
|
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, hasPassphrase] = await Promise.all([listAllBackups(), hasBackupPassphrase()]);
|
||||||
|
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>, enter the passphrase, 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}
|
||||||
|
hasStoredPassphrase={hasPassphrase}
|
||||||
|
maxUploadBytes={maxUploadBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRef, useState, useTransition } from "react";
|
||||||
|
import { JobProgress } from "../job-progress";
|
||||||
|
import { PassphraseNotice } from "../passphrase-notice";
|
||||||
|
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,
|
||||||
|
hasStoredPassphrase,
|
||||||
|
maxUploadBytes,
|
||||||
|
}: {
|
||||||
|
backups: BackupOption[];
|
||||||
|
preselected: string | null;
|
||||||
|
hasStoredPassphrase: boolean;
|
||||||
|
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 [passphrase, setPassphrase] = useState("");
|
||||||
|
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 [newPassphrase, setNewPassphrase] = 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, passphrase);
|
||||||
|
if (result.error) setError(result.error);
|
||||||
|
setPreview(result.preview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!sourceKey || !preview) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await startRestoreAction(sourceKey, passphrase, replace, confirmSlug);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewPassphrase(result.newPassphrase);
|
||||||
|
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">
|
||||||
|
{newPassphrase && <PassphraseNotice passphrase={newPassphrase} />}
|
||||||
|
<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">
|
||||||
|
<label htmlFor="passphrase" className="block text-sm font-semibold">
|
||||||
|
2. Passphrase
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassphrase(e.target.value);
|
||||||
|
resetPreview();
|
||||||
|
}}
|
||||||
|
placeholder={hasStoredPassphrase ? "Leave blank to use this server's stored passphrase" : "Required on a fresh server"}
|
||||||
|
className="w-full rounded-md border border-line px-3 py-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={inspect}
|
||||||
|
disabled={isPending || !sourceKey || (!passphrase && !hasStoredPassphrase)}
|
||||||
|
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isPending && !preview ? "Checking…" : "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">
|
||||||
|
“{preview.slug}” 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 “{preview.slug}” 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
resolvePushSettings,
|
resolvePushSettings,
|
||||||
saveEmailSettings,
|
saveEmailSettings,
|
||||||
saveGoogleCalendarSettings,
|
saveGoogleCalendarSettings,
|
||||||
|
saveOperatorSettings,
|
||||||
savePushSubject,
|
savePushSubject,
|
||||||
saveSuperAdminSettings,
|
saveSuperAdminSettings,
|
||||||
VapidKeysAlreadyExistError,
|
VapidKeysAlreadyExistError,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
type EmailSettingsInput,
|
type EmailSettingsInput,
|
||||||
type GoogleCalendarSettingsInput,
|
type GoogleCalendarSettingsInput,
|
||||||
} from "@/lib/global-settings";
|
} from "@/lib/global-settings";
|
||||||
|
import { sendOperatorEmail } from "@/lib/operator-email";
|
||||||
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
|
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
|
||||||
import { sendTestSmtpEmail } from "@/lib/test-email";
|
import { sendTestSmtpEmail } from "@/lib/test-email";
|
||||||
|
|
||||||
@@ -359,3 +361,53 @@ export async function reapplyGoogleCalendarSettingsAction(
|
|||||||
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
|
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
|
||||||
return { error: null, rollout };
|
return { error: null, rollout };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Monitoring & scheduler ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SaveOperatorSettingsState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
return { error: null, saved: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,167 @@
|
|||||||
|
"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 & 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'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>}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { readEmailSettingsView, readGoogleCalendarSettingsView, readPushSettingsView, readSuperAdminSettings } from "@/lib/global-settings";
|
import {
|
||||||
|
readEmailSettingsView,
|
||||||
|
readGoogleCalendarSettingsView,
|
||||||
|
readOperatorSettings,
|
||||||
|
readPushSettingsView,
|
||||||
|
readSuperAdminSettings,
|
||||||
|
} from "@/lib/global-settings";
|
||||||
|
import { readSchedulerStatus } from "@/lib/scheduler-status";
|
||||||
import { EmailSettingsForm } from "./email-settings-form";
|
import { EmailSettingsForm } from "./email-settings-form";
|
||||||
import { GoogleCalendarSettingsForm } from "./google-calendar-settings-form";
|
import { GoogleCalendarSettingsForm } from "./google-calendar-settings-form";
|
||||||
|
import { OperatorSettingsForm } from "./operator-settings-form";
|
||||||
import { PushSettingsForm } from "./push-settings-form";
|
import { PushSettingsForm } from "./push-settings-form";
|
||||||
import { SuperAdminSettingsForm } from "./super-admin-settings-form";
|
import { SuperAdminSettingsForm } from "./super-admin-settings-form";
|
||||||
|
|
||||||
@@ -29,11 +37,13 @@ export const dynamic = "force-dynamic";
|
|||||||
* branding, locale, monitoring) is a sibling here, not a rewrite.
|
* branding, locale, monitoring) is a sibling here, not a rewrite.
|
||||||
*/
|
*/
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const [email, push, superAdmin, googleCalendar] = await Promise.all([
|
const [email, push, superAdmin, googleCalendar, operator, schedulerStatus] = await Promise.all([
|
||||||
readEmailSettingsView(),
|
readEmailSettingsView(),
|
||||||
readPushSettingsView(),
|
readPushSettingsView(),
|
||||||
readSuperAdminSettings(),
|
readSuperAdminSettings(),
|
||||||
readGoogleCalendarSettingsView(),
|
readGoogleCalendarSettingsView(),
|
||||||
|
readOperatorSettings(),
|
||||||
|
readSchedulerStatus(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,6 +56,7 @@ export default async function SettingsPage() {
|
|||||||
<PushSettingsForm initial={push} />
|
<PushSettingsForm initial={push} />
|
||||||
<SuperAdminSettingsForm initial={superAdmin} />
|
<SuperAdminSettingsForm initial={superAdmin} />
|
||||||
<GoogleCalendarSettingsForm initial={googleCalendar} />
|
<GoogleCalendarSettingsForm initial={googleCalendar} />
|
||||||
|
<OperatorSettingsForm initial={operator} status={schedulerStatus} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const readBackupPassphraseMock = vi.fn();
|
||||||
|
vi.mock("@/lib/global-settings", () => ({ readBackupPassphrase: () => readBackupPassphraseMock() }));
|
||||||
|
|
||||||
|
import { performBackup } from "../backup";
|
||||||
|
import { backupFileName, listBackups } from "../backup-store";
|
||||||
|
import { saveBackupSettings } from "../backup-settings";
|
||||||
|
import { parseManifest } from "../backup-manifest";
|
||||||
|
|
||||||
|
const PASSPHRASE = "test-passphrase-123";
|
||||||
|
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);
|
||||||
|
readBackupPassphraseMock.mockReset().mockResolvedValue(PASSPHRASE);
|
||||||
|
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 encrypted archive
|
||||||
|
|
||||||
|
// 4. encrypted with the stored passphrase, written as .partial first
|
||||||
|
const create = sevenZipCreateMock.mock.calls[0]![0] as { archivePath: string; passphrase: string; entries: string[] };
|
||||||
|
expect(create.passphrase).toBe(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, PASSPHRASE);
|
||||||
|
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(PASSPHRASE);
|
||||||
|
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("fails clearly when no passphrase exists yet", async () => {
|
||||||
|
readBackupPassphraseMock.mockResolvedValue(null);
|
||||||
|
await expect(performBackup("acme", "regular", logs().ctx)).rejects.toThrow(/No backup passphrase/);
|
||||||
|
expect(runToFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
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,124 @@
|
|||||||
|
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", () => {
|
||||||
|
const passphrase = "correct-horse-battery";
|
||||||
|
|
||||||
|
it("create: 7z, encrypted headers, passphrase, 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"],
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
expect(spec.command).toBe("7zz");
|
||||||
|
expect(spec.cwd).toBe("/stacks/manager/tmp/s1");
|
||||||
|
expect(spec.args).toEqual([
|
||||||
|
"a", "-t7z", "-mx=3", "-mhe=on", `-p${passphrase}`, "-y", "-bd",
|
||||||
|
"/stacks/backups/acme/x.7z.partial", "manifest.json", "db.sql", "media.tar", "config",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test and stdout-extract commands", () => {
|
||||||
|
expect(sevenZipTestCommand("/a.7z", passphrase).args).toEqual(["t", "-t7z", `-p${passphrase}`, "-y", "-bd", "/a.7z"]);
|
||||||
|
expect(sevenZipExtractToStdoutCommand("/a.7z", passphrase, "db.sql").args).toEqual([
|
||||||
|
"x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", "/a.7z", "db.sql",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a short passphrase (an empty -p would hang on a prompt)", () => {
|
||||||
|
expect(() => sevenZipTestCommand("/a.7z", "")).toThrow(/at least 8/);
|
||||||
|
expect(() => sevenZipTestCommand("/a.7z", "short")).toThrow(/at least 8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SEVENZIP_BIN overrides the binary", () => {
|
||||||
|
process.env.SEVENZIP_BIN = "/opt/7zz";
|
||||||
|
try {
|
||||||
|
expect(sevenZipTestCommand("/a.7z", passphrase).command).toBe("/opt/7zz");
|
||||||
|
} finally {
|
||||||
|
delete process.env.SEVENZIP_BIN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,105 @@
|
|||||||
|
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: [] }));
|
||||||
|
const readBackupPassphraseMock = vi.fn();
|
||||||
|
vi.mock("@/lib/global-settings", () => ({
|
||||||
|
readBackupPassphrase: () => readBackupPassphraseMock(),
|
||||||
|
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` }));
|
||||||
|
readBackupPassphraseMock.mockReset().mockResolvedValue("a-stored-passphrase");
|
||||||
|
});
|
||||||
|
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,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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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 PASSPHRASE = "restore-passphrase-1";
|
||||||
|
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, _p: 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, passphrase: PASSPHRASE, 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, passphrase: PASSPHRASE, 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, passphrase: PASSPHRASE, 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", `-p${PASSPHRASE}`, "-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, passphrase: PASSPHRASE, 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, passphrase: PASSPHRASE, 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, passphrase: PASSPHRASE, 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, _p: string, member: string) =>
|
||||||
|
member === "manifest.json" ? manifest({ lastMigration: "20271231000000_from_the_future" }) : BACKED_UP_ENV,
|
||||||
|
);
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, 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, _p: string, member: string) =>
|
||||||
|
member === "manifest.json" ? manifest() : BACKED_UP_ENV.replace('DATABASE_NAME="acme"', 'DATABASE_NAME="other"'),
|
||||||
|
);
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/doesn't match its manifest/);
|
||||||
|
expect(recreateDatabaseMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a bad archive or wrong passphrase fails in the first step", async () => {
|
||||||
|
sevenZipTestMock.mockRejectedValue(new Error("7zz exited with code 2: Wrong password"));
|
||||||
|
await expect(performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx: makeCtx().ctx })).rejects.toThrow(/Wrong password/);
|
||||||
|
expect(assertDomainAvailableMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never logs the passphrase or any .env secret", async () => {
|
||||||
|
const { ctx, lines } = makeCtx();
|
||||||
|
await performRestore({ archivePath: ARCHIVE, passphrase: PASSPHRASE, replace: false, ctx });
|
||||||
|
const all = lines.join("\n");
|
||||||
|
expect(all).not.toContain(PASSPHRASE);
|
||||||
|
expect(all).not.toContain(DB_PASSWORD);
|
||||||
|
expect(all).not.toContain("s3cret-from-backup");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { BACKUP_KINDS, type BackupKind } from "./backup-manifest";
|
||||||
|
import { backupDir, backupsRoot } from "./manager-paths";
|
||||||
|
import { isValidSlug } from "./slug";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where archives live and how they're named/pruned:
|
||||||
|
*
|
||||||
|
* STACKS_DIR/backups/<slug>/<slug>__<UTC stamp>__<kind>.7z
|
||||||
|
*
|
||||||
|
* `kind` decides retention (see pruneBackups): regular backups (scheduled
|
||||||
|
* + "Back up now") are pruned to the configured count; safety backups
|
||||||
|
* (taken automatically before an update or a replacing restore) keep the
|
||||||
|
* newest SAFETY_KEEP so they can never evict the regular ones you might be
|
||||||
|
* about to restore from; final backups (taken just before deleting a
|
||||||
|
* business) are never pruned automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_KEEP = 2;
|
||||||
|
export const MAX_KEEP = 14;
|
||||||
|
export const SAFETY_KEEP = 2;
|
||||||
|
|
||||||
|
const FILE_PATTERN = /^([a-z][a-z0-9-]{1,48}[a-z0-9])__(\d{8}T\d{6}Z)__(regular|safety|final)\.7z$/;
|
||||||
|
|
||||||
|
export interface BackupEntry {
|
||||||
|
slug: string;
|
||||||
|
fileName: string;
|
||||||
|
kind: BackupKind;
|
||||||
|
createdAt: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stamp(date: Date): string {
|
||||||
|
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampToIso(value: string): string {
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(value)!;
|
||||||
|
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupFileName(slug: string, kind: BackupKind, at: Date): string {
|
||||||
|
return `${slug}__${stamp(at)}__${kind}.7z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupFileName(fileName: string): { slug: string; kind: BackupKind; createdAt: string } | null {
|
||||||
|
const match = FILE_PATTERN.exec(fileName);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, slug, rawStamp, kind] = match;
|
||||||
|
return { slug: slug!, kind: kind as BackupKind, createdAt: stampToIso(rawStamp!) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves a (slug, fileName) pair from untrusted input to an absolute path — rejects anything that isn't exactly a name this module produces. */
|
||||||
|
export function backupFilePath(slug: string, fileName: string): string {
|
||||||
|
const parsed = parseBackupFileName(fileName);
|
||||||
|
if (!isValidSlug(slug) || !parsed || parsed.slug !== slug) throw new Error("Invalid backup reference.");
|
||||||
|
return path.join(backupDir(slug), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureBackupDir(slug: string): Promise<string> {
|
||||||
|
const dir = backupDir(slug);
|
||||||
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBackups(slug: string): Promise<BackupEntry[]> {
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(backupDir(slug));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const entries: BackupEntry[] = [];
|
||||||
|
for (const fileName of files) {
|
||||||
|
const parsed = parseBackupFileName(fileName);
|
||||||
|
if (!parsed || parsed.slug !== slug) continue;
|
||||||
|
try {
|
||||||
|
const info = await stat(path.join(backupDir(slug), fileName));
|
||||||
|
entries.push({ slug, fileName, kind: parsed.kind, createdAt: parsed.createdAt, sizeBytes: info.size });
|
||||||
|
} catch {
|
||||||
|
// vanished between readdir and stat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries.sort((a, b) => b.fileName.localeCompare(a.fileName) || b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every backup of every business — including businesses that have since been deleted (their archives stay). */
|
||||||
|
export async function listAllBackups(): Promise<BackupEntry[]> {
|
||||||
|
let dirs: string[];
|
||||||
|
try {
|
||||||
|
dirs = await readdir(backupsRoot());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const all: BackupEntry[] = [];
|
||||||
|
for (const slug of dirs) {
|
||||||
|
if (!isValidSlug(slug)) continue;
|
||||||
|
all.push(...(await listBackups(slug)));
|
||||||
|
}
|
||||||
|
return all.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest-first within the kind; everything past `keep` is deleted. Never touches other kinds. Returns the removed file names. */
|
||||||
|
export async function pruneBackups(slug: string, kind: BackupKind, keep: number): Promise<string[]> {
|
||||||
|
if (kind === "final") return [];
|
||||||
|
const safeKeep = Math.max(1, Math.floor(keep));
|
||||||
|
const ofKind = (await listBackups(slug)).filter((b) => b.kind === kind);
|
||||||
|
const removed: string[] = [];
|
||||||
|
for (const old of ofKind.slice(safeKeep)) {
|
||||||
|
await rm(backupFilePath(slug, old.fileName), { force: true });
|
||||||
|
removed.push(old.fileName);
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBackup(slug: string, fileName: string): Promise<void> {
|
||||||
|
await rm(backupFilePath(slug, fileName), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A backup that crashed mid-write leaves `*.partial` files; under the business lock nothing else can be writing, so they're all dead. */
|
||||||
|
export async function removePartialFiles(slug: string): Promise<void> {
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(backupDir(slug));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith(".partial")) await rm(path.join(backupDir(slug), file), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupKind(value: string): value is BackupKind {
|
||||||
|
return (BACKUP_KINDS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { chmod, copyFile, mkdir, open, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { BACKUP_MEMBERS, buildManifest, type BackupKind } from "./backup-manifest";
|
||||||
|
import { readBackupSettings } from "./backup-settings";
|
||||||
|
import { backupFileName, ensureBackupDir, pruneBackups, removePartialFiles, SAFETY_KEEP } from "./backup-store";
|
||||||
|
import { containerExists } from "./docker";
|
||||||
|
import { mediaCopyOutCommand, mediaVolumeTarCommand, mysqlQueryCommand, mysqldumpCommand } from "./docker-commands";
|
||||||
|
import { readEnvFile, readEnvValue } from "./env-file";
|
||||||
|
import { readBackupPassphrase } from "./global-settings";
|
||||||
|
import { stagingRoot } from "./manager-paths";
|
||||||
|
import { runCapture, runToFile } from "./process";
|
||||||
|
import { randomHex } from "./random";
|
||||||
|
import { sevenZipCreate, sevenZipTest } from "./sevenzip";
|
||||||
|
import { stackDir, stackExists, stacksRoot } from "./stacks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one encrypted archive for a business:
|
||||||
|
*
|
||||||
|
* manifest.json what this is (slug, domain, db names, last migration…)
|
||||||
|
* db.sql mysqldump, streamed straight to disk
|
||||||
|
* media.tar /app/storage/media, streamed out of the container/volume
|
||||||
|
* config/.env, config/compose.yaml, config/site.caddy
|
||||||
|
*
|
||||||
|
* packaged with `7zz a -mhe=on -p…`, written as `<name>.7z.partial`,
|
||||||
|
* verified with `7zz t`, and only then renamed into place — so a crash or
|
||||||
|
* a failed verify can never leave something that looks like a good
|
||||||
|
* backup. Does NOT take the business lock; callers (the backup job, or a
|
||||||
|
* restore's safety step) already hold it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BACKUP_STEPS = [
|
||||||
|
{ id: "dump", label: "Dump the database" },
|
||||||
|
{ id: "media", label: "Copy media files" },
|
||||||
|
{ id: "config", label: "Collect configuration" },
|
||||||
|
{ id: "archive", label: "Encrypt and compress" },
|
||||||
|
{ id: "verify", label: "Verify the archive" },
|
||||||
|
{ id: "finish", label: "Save and prune old backups" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface BackupRunContext {
|
||||||
|
log(line: string): void;
|
||||||
|
/** Optional: wraps each phase as a job step. Absent when nested inside another job. */
|
||||||
|
step?<T>(stepId: string, fn: () => Promise<T>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupResult {
|
||||||
|
slug: string;
|
||||||
|
fileName: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
kind: BackupKind;
|
||||||
|
pruned: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runStep<T>(ctx: BackupRunContext, id: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
return ctx.step ? ctx.step(id, fn) : fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertDumpComplete(dumpPath: string): Promise<void> {
|
||||||
|
const info = await stat(dumpPath);
|
||||||
|
const handle = await open(dumpPath, "r");
|
||||||
|
try {
|
||||||
|
const length = Math.min(512, info.size);
|
||||||
|
const buffer = Buffer.alloc(length);
|
||||||
|
await handle.read(buffer, 0, length, info.size - length);
|
||||||
|
if (!buffer.toString("utf8").includes("-- Dump completed")) {
|
||||||
|
throw new Error("The database dump looks incomplete (no completion marker at the end).");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLastMigration(databaseName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await runCapture(
|
||||||
|
mysqlQueryCommand(databaseName, "SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL ORDER BY migration_name DESC LIMIT 1"),
|
||||||
|
{ maxBytes: 4096, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
return stdout.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performBackup(slug: string, kind: BackupKind, ctx: BackupRunContext, now: Date = new Date()): Promise<BackupResult> {
|
||||||
|
if (!(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||||
|
const dir = stackDir(slug);
|
||||||
|
|
||||||
|
const envContent = await readEnvFile(path.join(dir, ".env"));
|
||||||
|
if (!envContent) throw new Error(`"${slug}" has no .env — it was never fully provisioned, so there is nothing to back up.`);
|
||||||
|
const databaseName = readEnvValue(envContent, "DATABASE_NAME");
|
||||||
|
const databaseUser = readEnvValue(envContent, "DATABASE_USER");
|
||||||
|
const domain = readEnvValue(envContent, "DOMAIN");
|
||||||
|
if (!databaseName || !databaseUser || !domain) throw new Error(`"${slug}"'s .env is missing DATABASE_NAME/DATABASE_USER/DOMAIN.`);
|
||||||
|
|
||||||
|
const passphrase = await readBackupPassphrase();
|
||||||
|
if (!passphrase) {
|
||||||
|
throw new Error("No backup passphrase exists yet. Enable backups for a business (or use Back up now) from the Backups page so one is generated and shown to you.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outDir = await ensureBackupDir(slug);
|
||||||
|
await removePartialFiles(slug);
|
||||||
|
const finalName = backupFileName(slug, kind, now);
|
||||||
|
const partialPath = path.join(outDir, `${finalName}.partial`);
|
||||||
|
const finalPath = path.join(outDir, finalName);
|
||||||
|
|
||||||
|
const stagingDir = path.join(stagingRoot(), `backup-${slug}-${randomHex(4)}`);
|
||||||
|
await mkdir(path.join(stagingDir, "config"), { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dumpPath = path.join(stagingDir, BACKUP_MEMBERS.database);
|
||||||
|
await runStep(ctx, "dump", async () => {
|
||||||
|
ctx.log(`Dumping database ${databaseName}…`);
|
||||||
|
await runToFile(mysqldumpCommand(databaseName), dumpPath);
|
||||||
|
await assertDumpComplete(dumpPath);
|
||||||
|
ctx.log(`Database dump written (${(await stat(dumpPath)).size} bytes).`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaPath = path.join(stagingDir, BACKUP_MEMBERS.media);
|
||||||
|
await runStep(ctx, "media", async () => {
|
||||||
|
const container = `next-chair-${slug}`;
|
||||||
|
if (await containerExists(container)) {
|
||||||
|
ctx.log("Copying media from the running container…");
|
||||||
|
await runToFile(mediaCopyOutCommand(container), mediaPath);
|
||||||
|
} else {
|
||||||
|
ctx.log("Container isn't present (stack stopped) — reading media from its volume…");
|
||||||
|
await runToFile(mediaVolumeTarCommand(slug), mediaPath);
|
||||||
|
}
|
||||||
|
ctx.log(`Media archive written (${(await stat(mediaPath)).size} bytes).`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runStep(ctx, "config", async () => {
|
||||||
|
await copyFile(path.join(dir, ".env"), path.join(stagingDir, BACKUP_MEMBERS.env));
|
||||||
|
await copyFile(path.join(dir, "compose.yaml"), path.join(stagingDir, BACKUP_MEMBERS.compose));
|
||||||
|
let hasCaddySite = false;
|
||||||
|
try {
|
||||||
|
await copyFile(path.join(stacksRoot(), "infra", "sites", `${slug}.caddy`), path.join(stagingDir, BACKUP_MEMBERS.caddy));
|
||||||
|
hasCaddySite = true;
|
||||||
|
} catch {
|
||||||
|
ctx.log("No Caddy site file found for this business — skipping it.");
|
||||||
|
}
|
||||||
|
const lastMigration = await readLastMigration(databaseName);
|
||||||
|
const manifest = buildManifest({
|
||||||
|
slug,
|
||||||
|
name: readEnvValue(envContent, "BUSINESS_NAME"),
|
||||||
|
domain,
|
||||||
|
databaseName,
|
||||||
|
databaseUser,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
kind,
|
||||||
|
lastMigration,
|
||||||
|
appImage: process.env.NEXTCHAIR_IMAGE ?? null,
|
||||||
|
hasCaddySite,
|
||||||
|
});
|
||||||
|
await writeFile(path.join(stagingDir, BACKUP_MEMBERS.manifest), JSON.stringify(manifest, null, 2), { encoding: "utf8", mode: 0o600 });
|
||||||
|
ctx.log(`Manifest written (last migration: ${lastMigration ?? "unknown"}).`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runStep(ctx, "archive", async () => {
|
||||||
|
ctx.log("Encrypting and compressing…");
|
||||||
|
await rm(partialPath, { force: true });
|
||||||
|
await sevenZipCreate({
|
||||||
|
archivePath: partialPath,
|
||||||
|
cwd: stagingDir,
|
||||||
|
entries: [BACKUP_MEMBERS.manifest, BACKUP_MEMBERS.database, BACKUP_MEMBERS.media, "config"],
|
||||||
|
passphrase,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await runStep(ctx, "verify", async () => {
|
||||||
|
ctx.log("Verifying the archive (7zz t)…");
|
||||||
|
await sevenZipTest(partialPath, passphrase);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await runStep(ctx, "finish", async () => {
|
||||||
|
await rename(partialPath, finalPath);
|
||||||
|
await chmod(finalPath, 0o600); // 7zz honours the umask; the archive (encrypted or not) should be owner-only
|
||||||
|
const sizeBytes = (await stat(finalPath)).size;
|
||||||
|
let pruned: string[] = [];
|
||||||
|
if (kind === "regular") {
|
||||||
|
const { keep } = await readBackupSettings(slug);
|
||||||
|
pruned = await pruneBackups(slug, "regular", keep);
|
||||||
|
} else if (kind === "safety") {
|
||||||
|
pruned = await pruneBackups(slug, "safety", SAFETY_KEEP);
|
||||||
|
}
|
||||||
|
if (pruned.length > 0) ctx.log(`Removed ${pruned.length} old backup(s): ${pruned.join(", ")}`);
|
||||||
|
ctx.log(`Backup saved: ${finalName} (${sizeBytes} bytes).`);
|
||||||
|
return { slug, fileName: finalName, sizeBytes, kind, pruned };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await rm(partialPath, { force: true });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await rm(stagingDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { CommandSpec } from "./process";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure command builders for every `docker` call the backup/restore code
|
||||||
|
* streams (see process.ts). Kept separate from docker.ts (which actually
|
||||||
|
* runs `docker compose`) so the exact argument arrays are plain,
|
||||||
|
* unit-testable values: no shell, no string interpolation into a command
|
||||||
|
* line, and the MySQL root password travels as the MYSQL_PWD environment
|
||||||
|
* variable of the docker CLI (`-e MYSQL_PWD` with no value forwards the
|
||||||
|
* client's own env var into the exec) — never as an argument, so it never
|
||||||
|
* shows up in a process listing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MEDIA_MOUNT_PATH = "/app/storage/media";
|
||||||
|
/** Uid/gid the business image's `nextchair` user runs as (see the main Dockerfile). */
|
||||||
|
export const APP_UID_GID = "1001:1001";
|
||||||
|
|
||||||
|
export function mysqlContainerName(): string {
|
||||||
|
return process.env.MYSQL_CONTAINER_NAME ?? "next-chair-mysql";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appImage(): string {
|
||||||
|
return process.env.NEXTCHAIR_IMAGE ?? "nextchair-app:latest";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaVolumeName(slug: string): string {
|
||||||
|
return `next-chair-media-${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mysqlEnv(): Record<string, string> {
|
||||||
|
const password = process.env.MYSQL_ROOT_PASSWORD;
|
||||||
|
if (!password) throw new Error("MYSQL_ROOT_PASSWORD must be set.");
|
||||||
|
return { MYSQL_PWD: password };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mysqldumpCommand(databaseName: string): CommandSpec {
|
||||||
|
return {
|
||||||
|
command: "docker",
|
||||||
|
args: [
|
||||||
|
"exec",
|
||||||
|
"-e",
|
||||||
|
"MYSQL_PWD",
|
||||||
|
mysqlContainerName(),
|
||||||
|
"mysqldump",
|
||||||
|
"-uroot",
|
||||||
|
"--single-transaction",
|
||||||
|
"--routines",
|
||||||
|
"--triggers",
|
||||||
|
"--no-tablespaces",
|
||||||
|
"--hex-blob",
|
||||||
|
"--default-character-set=utf8mb4",
|
||||||
|
databaseName,
|
||||||
|
],
|
||||||
|
env: mysqlEnv(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mysqlImportCommand(databaseName: string): CommandSpec {
|
||||||
|
return {
|
||||||
|
command: "docker",
|
||||||
|
args: ["exec", "-i", "-e", "MYSQL_PWD", mysqlContainerName(), "mysql", "-uroot", "--default-character-set=utf8mb4", databaseName],
|
||||||
|
env: mysqlEnv(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `-N -B`: no header, tab-separated — for reading a single value back. */
|
||||||
|
export function mysqlQueryCommand(databaseName: string, sql: string): CommandSpec {
|
||||||
|
return {
|
||||||
|
command: "docker",
|
||||||
|
args: ["exec", "-e", "MYSQL_PWD", mysqlContainerName(), "mysql", "-uroot", "-N", "-B", databaseName, "-e", sql],
|
||||||
|
env: mysqlEnv(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tar stream of /app/storage/media (entries are prefixed `media/`) from a business's existing container. */
|
||||||
|
export function mediaCopyOutCommand(containerName: string): CommandSpec {
|
||||||
|
return { command: "docker", args: ["cp", `${containerName}:${MEDIA_MOUNT_PATH}`, "-"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same tar layout as mediaCopyOutCommand, read straight from the named volume — used when the container has been removed (a stopped stack is `compose down`, which deletes it). */
|
||||||
|
export function mediaVolumeTarCommand(slug: string): CommandSpec {
|
||||||
|
return {
|
||||||
|
command: "docker",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-v",
|
||||||
|
`${mediaVolumeName(slug)}:${MEDIA_MOUNT_PATH}:ro`,
|
||||||
|
"--entrypoint",
|
||||||
|
"tar",
|
||||||
|
appImage(),
|
||||||
|
"-c",
|
||||||
|
"-C",
|
||||||
|
"/app/storage",
|
||||||
|
"media",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unpacks the tar stream on stdin into the (fresh) media volume and hands it to the app user. The shell script is a constant — nothing is interpolated into it. */
|
||||||
|
export function mediaRestoreCommand(slug: string): CommandSpec {
|
||||||
|
return {
|
||||||
|
command: "docker",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--user",
|
||||||
|
"0",
|
||||||
|
"-v",
|
||||||
|
`${mediaVolumeName(slug)}:${MEDIA_MOUNT_PATH}`,
|
||||||
|
"--entrypoint",
|
||||||
|
"sh",
|
||||||
|
appImage(),
|
||||||
|
"-c",
|
||||||
|
`tar -x -C /app/storage && chown -R ${APP_UID_GID} ${MEDIA_MOUNT_PATH}`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageMigrationsCommand(): CommandSpec {
|
||||||
|
return { command: "docker", args: ["run", "--rm", "--entrypoint", "ls", appImage(), "/app/prisma/migrations"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containerExistsCommand(containerName: string): CommandSpec {
|
||||||
|
return { command: "docker", args: ["container", "inspect", "--format", "{{.Id}}", containerName] };
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { containerExistsCommand, imageMigrationsCommand } from "./docker-commands";
|
||||||
|
import { runCapture } from "./process";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -35,6 +37,11 @@ export async function composeUp(stackDir: string, options: { forceRecreate?: boo
|
|||||||
await runDockerCompose(stackDir, args);
|
await runDockerCompose(stackDir, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Creates the stack's container(s) and volume(s) without starting them — lets a restore populate the media volume before the app's first boot. */
|
||||||
|
export async function composeCreate(stackDir: string): Promise<void> {
|
||||||
|
await runDockerCompose(stackDir, ["create"]);
|
||||||
|
}
|
||||||
|
|
||||||
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
|
export async function composeDown(stackDir: string, options: { removeVolumes?: boolean } = {}): Promise<void> {
|
||||||
const args = ["down"];
|
const args = ["down"];
|
||||||
if (options.removeVolumes) args.push("-v");
|
if (options.removeVolumes) args.push("-v");
|
||||||
@@ -89,3 +96,22 @@ export async function reloadCaddy(): Promise<void> {
|
|||||||
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
|
const containerName = process.env.CADDY_CONTAINER_NAME ?? "next-chair-caddy";
|
||||||
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
|
await execFileAsync("docker", ["exec", containerName, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function containerExists(containerName: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await runCapture(containerExistsCommand(containerName), { maxBytes: 4096 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migration folder names baked into the shared app image (sorted) — read by running `ls` in a throwaway container, so it works on a fresh server with no business running. */
|
||||||
|
export async function listImageMigrations(): Promise<string[]> {
|
||||||
|
const { stdout } = await runCapture(imageMigrationsCommand(), { maxBytes: 256 * 1024, timeoutMs: 120_000 });
|
||||||
|
return stdout
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => /^\d{14}_/.test(line))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto";
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import webpush from "web-push";
|
import webpush from "web-push";
|
||||||
|
import { withMutex } from "./singleton";
|
||||||
import { stacksRoot } from "./stacks";
|
import { stacksRoot } from "./stacks";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,11 +62,46 @@ interface StoredGoogleCalendarSettings {
|
|||||||
encryptedClientSecret: EncryptedPayload | null;
|
encryptedClientSecret: EncryptedPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-facing monitoring + scheduler knobs (Settings > Monitoring).
|
||||||
|
* Not secret, so stored and returned in plain.
|
||||||
|
*/
|
||||||
|
export interface OperatorSettings {
|
||||||
|
/** Where health / backup alerts go. Blank = alerting is off (nothing is emailed). */
|
||||||
|
alertEmail: string;
|
||||||
|
alertOnHealth: boolean;
|
||||||
|
alertOnRecovery: boolean;
|
||||||
|
/** While a business is still down/degraded, repeat the alert once a day. */
|
||||||
|
dailyReminder: boolean;
|
||||||
|
alertOnBackupFailure: boolean;
|
||||||
|
/** How often the built-in scheduler calls each business's notification cron endpoint. */
|
||||||
|
notificationsIntervalSeconds: number;
|
||||||
|
healthIntervalMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_OPERATOR_SETTINGS: OperatorSettings = {
|
||||||
|
alertEmail: "",
|
||||||
|
alertOnHealth: true,
|
||||||
|
alertOnRecovery: true,
|
||||||
|
dailyReminder: true,
|
||||||
|
alertOnBackupFailure: true,
|
||||||
|
notificationsIntervalSeconds: 30,
|
||||||
|
healthIntervalMinutes: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Persisted shape — the passphrase only ever holds the encrypted envelope. One passphrase protects every business's backups, so a single value is enough to restore on a fresh server. */
|
||||||
|
interface StoredBackupSettings {
|
||||||
|
encryptedPassphrase: EncryptedPayload | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface StoredGlobalSettings {
|
interface StoredGlobalSettings {
|
||||||
email: StoredEmailSettings | null;
|
email: StoredEmailSettings | null;
|
||||||
push: StoredPushSettings | null;
|
push: StoredPushSettings | null;
|
||||||
superAdmin: SuperAdminSettings | null;
|
superAdmin: SuperAdminSettings | null;
|
||||||
googleCalendar: StoredGoogleCalendarSettings | null;
|
googleCalendar: StoredGoogleCalendarSettings | null;
|
||||||
|
operator: OperatorSettings | null;
|
||||||
|
backups: StoredBackupSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LastTestResult {
|
export interface LastTestResult {
|
||||||
@@ -157,10 +193,12 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
|
|||||||
push: parsed.push ?? null,
|
push: parsed.push ?? null,
|
||||||
superAdmin: parsed.superAdmin ?? null,
|
superAdmin: parsed.superAdmin ?? null,
|
||||||
googleCalendar: parsed.googleCalendar ?? null,
|
googleCalendar: parsed.googleCalendar ?? null,
|
||||||
|
operator: parsed.operator ?? null,
|
||||||
|
backups: parsed.backups ?? null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
|
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render.
|
||||||
return { email: null, push: null, superAdmin: null, googleCalendar: null };
|
return { email: null, push: null, superAdmin: null, googleCalendar: null, operator: null, backups: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,3 +528,86 @@ export async function saveGoogleCalendarSettings(input: GoogleCalendarSettingsIn
|
|||||||
|
|
||||||
return { clientId: next.clientId, clientSecret: resolvedSecret };
|
return { clientId: next.clientId, clientSecret: resolvedSecret };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Operator monitoring / scheduler ──────────────────────────────────────
|
||||||
|
|
||||||
|
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readOperatorSettings(): Promise<OperatorSettings> {
|
||||||
|
const { operator } = await readStoredSettings();
|
||||||
|
// Merge over defaults so a settings file written before a field existed still yields a complete object.
|
||||||
|
const merged = { ...DEFAULT_OPERATOR_SETTINGS, ...(operator ?? {}) };
|
||||||
|
return {
|
||||||
|
...merged,
|
||||||
|
notificationsIntervalSeconds: clampInt(merged.notificationsIntervalSeconds, 10, 3600, 30),
|
||||||
|
healthIntervalMinutes: clampInt(merged.healthIntervalMinutes, 1, 60, 3),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOperatorSettings(input: OperatorSettings): Promise<OperatorSettings> {
|
||||||
|
const current = await readStoredSettings();
|
||||||
|
const next: OperatorSettings = {
|
||||||
|
alertEmail: input.alertEmail.trim(),
|
||||||
|
alertOnHealth: input.alertOnHealth,
|
||||||
|
alertOnRecovery: input.alertOnRecovery,
|
||||||
|
dailyReminder: input.dailyReminder,
|
||||||
|
alertOnBackupFailure: input.alertOnBackupFailure,
|
||||||
|
notificationsIntervalSeconds: clampInt(input.notificationsIntervalSeconds, 10, 3600, 30),
|
||||||
|
healthIntervalMinutes: clampInt(input.healthIntervalMinutes, 1, 60, 3),
|
||||||
|
};
|
||||||
|
await writeStoredSettings({ ...current, operator: next });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backup passphrase ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function hasBackupPassphrase(): Promise<boolean> {
|
||||||
|
const { backups } = await readStoredSettings();
|
||||||
|
return Boolean(backups?.encryptedPassphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server-side only — the decrypted passphrase used for scheduled/manual archives. Null until one has been generated. */
|
||||||
|
export async function readBackupPassphrase(): Promise<string | null> {
|
||||||
|
const { backups } = await readStoredSettings();
|
||||||
|
return backups?.encryptedPassphrase ? decrypt(backups.encryptedPassphrase) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function backupPassphraseCreatedAt(): Promise<string | null> {
|
||||||
|
const { backups } = await readStoredSettings();
|
||||||
|
return backups?.createdAt ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored passphrase, generating (and encrypting) one on first
|
||||||
|
* use. `created` is true only on that first call — the one moment the UI
|
||||||
|
* shows the plaintext to the operator so they can save it somewhere safe;
|
||||||
|
* afterwards it is never displayed again (a fresh server needs it typed
|
||||||
|
* in at restore time).
|
||||||
|
*/
|
||||||
|
export function ensureBackupPassphrase(): Promise<{ passphrase: string; created: boolean }> {
|
||||||
|
// Mutex: a scheduled backup and a manual one racing on a fresh install must not each mint a different passphrase.
|
||||||
|
return withMutex("backup-passphrase", async () => {
|
||||||
|
const existing = await readBackupPassphrase();
|
||||||
|
if (existing) return { passphrase: existing, created: false };
|
||||||
|
return { passphrase: await storeNewBackupPassphrase(), created: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the passphrase (older archives still need the old one). Returns the new plaintext, shown once. */
|
||||||
|
export async function rotateBackupPassphrase(): Promise<string> {
|
||||||
|
return storeNewBackupPassphrase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeNewBackupPassphrase(): Promise<string> {
|
||||||
|
const passphrase = randomBytes(18).toString("base64url");
|
||||||
|
const current = await readStoredSettings();
|
||||||
|
await writeStoredSettings({
|
||||||
|
...current,
|
||||||
|
backups: { encryptedPassphrase: encrypt(passphrase), createdAt: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readEnvFile, readEnvValue } from "./env-file";
|
||||||
|
import { readOperatorSettings, type OperatorSettings } from "./global-settings";
|
||||||
|
import { fetchHealthSnapshot, type HealthSnapshot, type HealthVerdict } from "./health";
|
||||||
|
import { readJsonFile, writeJsonFileAtomic } from "./json-file";
|
||||||
|
import { getActiveLock } from "./locks";
|
||||||
|
import { healthStatePath } from "./manager-paths";
|
||||||
|
import { sendOperatorEmail } from "./operator-email";
|
||||||
|
import { withMutex } from "./singleton";
|
||||||
|
import { stackDir } from "./stacks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-business health state machine (ok / degraded / down) with the
|
||||||
|
* alerting rules from the plan:
|
||||||
|
*
|
||||||
|
* - two consecutive bad checks before the first alert (one failed probe
|
||||||
|
* during a restart or a slow request is not an incident);
|
||||||
|
* - an email on every state change and on recovery;
|
||||||
|
* - while a business is still bad, one reminder per day;
|
||||||
|
* - businesses being stopped on purpose, or mid-update/restore, are not
|
||||||
|
* probed at all.
|
||||||
|
*
|
||||||
|
* `applyObservation` is a pure function (all the policy, unit-tested);
|
||||||
|
* `runHealthCheck` wires it to the probe, the state file and the email.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BAD_THRESHOLD = 2;
|
||||||
|
export const RECOVER_THRESHOLD = 1;
|
||||||
|
export const REMINDER_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
/** An alert that couldn't be emailed (SMTP down) is retried each check, then given up on after this long. */
|
||||||
|
const UNSENT_ALERT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type HealthAlertType = "problem" | "changed" | "recovered" | "reminder";
|
||||||
|
|
||||||
|
export interface HealthAlert {
|
||||||
|
type: HealthAlertType;
|
||||||
|
slug: string;
|
||||||
|
from: HealthVerdict;
|
||||||
|
to: HealthVerdict;
|
||||||
|
reasons: string[];
|
||||||
|
/** When the current state began. */
|
||||||
|
since: string;
|
||||||
|
raisedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthRecord {
|
||||||
|
status: HealthVerdict;
|
||||||
|
since: string;
|
||||||
|
reasons: string[];
|
||||||
|
pending: { verdict: HealthVerdict; count: number } | null;
|
||||||
|
lastAlertAt: string | null;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
unsentAlert: HealthAlert | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialRecord(now: Date): HealthRecord {
|
||||||
|
const iso = now.toISOString();
|
||||||
|
return { status: "ok", since: iso, reasons: [], pending: null, lastAlertAt: null, lastCheckedAt: iso, unsentAlert: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Observation {
|
||||||
|
verdict: HealthVerdict;
|
||||||
|
reasons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyObservation(
|
||||||
|
slug: string,
|
||||||
|
previous: HealthRecord | undefined,
|
||||||
|
observation: Observation,
|
||||||
|
now: Date,
|
||||||
|
options: { dailyReminder: boolean },
|
||||||
|
): { next: HealthRecord; alert: HealthAlert | null } {
|
||||||
|
const prev = previous ?? initialRecord(now);
|
||||||
|
const iso = now.toISOString();
|
||||||
|
|
||||||
|
if (observation.verdict === prev.status) {
|
||||||
|
const next: HealthRecord = { ...prev, pending: null, reasons: observation.reasons, lastCheckedAt: iso };
|
||||||
|
// Baseline is the last alert, or — if none was ever delivered (alerting was off, SMTP down) — when the bad state began.
|
||||||
|
const baseline = Date.parse(prev.lastAlertAt ?? prev.since);
|
||||||
|
const reminderDue = prev.status !== "ok" && options.dailyReminder && now.getTime() - baseline >= REMINDER_INTERVAL_MS;
|
||||||
|
const alert: HealthAlert | null = reminderDue
|
||||||
|
? { type: "reminder", slug, from: prev.status, to: prev.status, reasons: observation.reasons, since: prev.since, raisedAt: iso }
|
||||||
|
: null;
|
||||||
|
return { next, alert };
|
||||||
|
}
|
||||||
|
|
||||||
|
const threshold = observation.verdict === "ok" ? RECOVER_THRESHOLD : BAD_THRESHOLD;
|
||||||
|
const count = prev.pending && prev.pending.verdict === observation.verdict ? prev.pending.count + 1 : 1;
|
||||||
|
|
||||||
|
if (count < threshold) {
|
||||||
|
return { next: { ...prev, pending: { verdict: observation.verdict, count }, lastCheckedAt: iso }, alert: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const type: HealthAlertType = observation.verdict === "ok" ? "recovered" : prev.status === "ok" ? "problem" : "changed";
|
||||||
|
const next: HealthRecord = {
|
||||||
|
...prev,
|
||||||
|
status: observation.verdict,
|
||||||
|
since: iso,
|
||||||
|
reasons: observation.reasons,
|
||||||
|
pending: null,
|
||||||
|
lastCheckedAt: iso,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
// For a recovery, `since` is when the BAD period began (so the email can say how long it lasted).
|
||||||
|
alert: { type, slug, from: prev.status, to: observation.verdict, reasons: observation.reasons, since: type === "recovered" ? prev.since : iso, raisedAt: iso },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State file ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface HealthStateFile {
|
||||||
|
businesses: Record<string, HealthRecord>;
|
||||||
|
/** Slugs deliberately stopped from the Manager — not probed, not alerted on. */
|
||||||
|
stopped: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readState(): Promise<HealthStateFile> {
|
||||||
|
const file = await readJsonFile<Partial<HealthStateFile>>(healthStatePath(), {});
|
||||||
|
return { businesses: file.businesses ?? {}, stopped: file.stopped ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readHealthRecords(): Promise<Record<string, HealthRecord>> {
|
||||||
|
return (await readState()).businesses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by the Stop / Start actions so an intentional stop doesn't page anyone. */
|
||||||
|
export function setIntentionallyStopped(slug: string, stopped: boolean, now: Date = new Date()): Promise<void> {
|
||||||
|
return withMutex("health-state", async () => {
|
||||||
|
const state = await readState();
|
||||||
|
if (stopped) state.stopped[slug] = now.toISOString();
|
||||||
|
else {
|
||||||
|
delete state.stopped[slug];
|
||||||
|
// Back from a deliberate stop: judge it afresh.
|
||||||
|
delete state.businesses[slug];
|
||||||
|
}
|
||||||
|
await writeJsonFileAtomic(healthStatePath(), state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alert email ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildAlertEmail(alert: HealthAlert, label: string, domain: string | null): { subject: string; text: string } {
|
||||||
|
const where = domain ? `${label} (${domain})` : label;
|
||||||
|
const reasons = alert.reasons.length > 0 ? alert.reasons.map((r) => ` - ${r}`).join("\n") : " (no detail reported)";
|
||||||
|
switch (alert.type) {
|
||||||
|
case "recovered":
|
||||||
|
return {
|
||||||
|
subject: `${where} has recovered`,
|
||||||
|
text: `${where} is healthy again (was ${alert.from} since ${alert.since}).\n\nRecovered at: ${alert.raisedAt}\n`,
|
||||||
|
};
|
||||||
|
case "reminder":
|
||||||
|
return {
|
||||||
|
subject: `Still ${alert.to.toUpperCase()}: ${where}`,
|
||||||
|
text: `${where} has been ${alert.to} since ${alert.since}.\n\nCurrent reasons:\n${reasons}\n\nThis is the daily reminder while it stays unresolved.\n`,
|
||||||
|
};
|
||||||
|
case "changed":
|
||||||
|
return {
|
||||||
|
subject: `${where} is now ${alert.to.toUpperCase()} (was ${alert.from})`,
|
||||||
|
text: `${where} changed from ${alert.from} to ${alert.to}.\n\nReasons:\n${reasons}\n\nTime: ${alert.raisedAt}\n`,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
subject: `${where} is ${alert.to.toUpperCase()}`,
|
||||||
|
text: `${where} failed ${BAD_THRESHOLD} consecutive health checks and is now ${alert.to}.\n\nReasons:\n${reasons}\n\nTime: ${alert.raisedAt}\n`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSend(alert: HealthAlert, settings: OperatorSettings): boolean {
|
||||||
|
if (!settings.alertEmail) return false;
|
||||||
|
if (alert.type === "recovered") return settings.alertOnRecovery;
|
||||||
|
if (alert.type === "reminder") return settings.alertOnHealth && settings.dailyReminder;
|
||||||
|
return settings.alertOnHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckOutcome {
|
||||||
|
skipped?: string;
|
||||||
|
record?: HealthRecord;
|
||||||
|
alertSent?: HealthAlertType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckDeps {
|
||||||
|
probe: (slug: string) => Promise<HealthSnapshot>;
|
||||||
|
sendEmail: (subject: string, text: string) => Promise<{ ok: boolean; message: string }>;
|
||||||
|
now: () => Date;
|
||||||
|
isBusy: (slug: string) => Promise<boolean>;
|
||||||
|
readSettings: () => Promise<OperatorSettings>;
|
||||||
|
describe: (slug: string) => Promise<{ label: string; domain: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeBusiness(slug: string): Promise<{ label: string; domain: string | null }> {
|
||||||
|
const env = await readEnvFile(path.join(stackDir(slug), ".env"));
|
||||||
|
return { label: (env && readEnvValue(env, "BUSINESS_NAME")) || slug, domain: env ? readEnvValue(env, "DOMAIN") : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDeps(): HealthCheckDeps {
|
||||||
|
return {
|
||||||
|
probe: fetchHealthSnapshot,
|
||||||
|
sendEmail: sendOperatorEmail,
|
||||||
|
now: () => new Date(),
|
||||||
|
isBusy: async (slug) => (await getActiveLock(slug)) !== null,
|
||||||
|
readSettings: readOperatorSettings,
|
||||||
|
describe: describeBusiness,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHealthCheck(slug: string, overrides: Partial<HealthCheckDeps> = {}): Promise<HealthCheckOutcome> {
|
||||||
|
const deps = { ...defaultDeps(), ...overrides };
|
||||||
|
|
||||||
|
const stateBefore = await readState();
|
||||||
|
if (slug in stateBefore.stopped) return { skipped: "intentionally stopped" };
|
||||||
|
if (await deps.isBusy(slug)) return { skipped: "a job holds this business (update/backup/restore)" };
|
||||||
|
|
||||||
|
// Probe outside the state mutex — it can take seconds.
|
||||||
|
const snapshot = await deps.probe(slug);
|
||||||
|
const settings = await deps.readSettings();
|
||||||
|
const now = deps.now();
|
||||||
|
|
||||||
|
const { record, toSend } = await withMutex("health-state", async () => {
|
||||||
|
const state = await readState();
|
||||||
|
const { next, alert } = applyObservation(slug, state.businesses[slug], { verdict: snapshot.verdict, reasons: snapshot.reasons }, now, {
|
||||||
|
dailyReminder: settings.dailyReminder,
|
||||||
|
});
|
||||||
|
// A previous alert that never got emailed goes out first (unless it's gone stale).
|
||||||
|
const carried = state.businesses[slug]?.unsentAlert ?? null;
|
||||||
|
const carriedFresh = carried && now.getTime() - Date.parse(carried.raisedAt) < UNSENT_ALERT_TTL_MS ? carried : null;
|
||||||
|
const pick = alert ?? carriedFresh;
|
||||||
|
next.unsentAlert = alert ? null : carriedFresh;
|
||||||
|
state.businesses[slug] = next;
|
||||||
|
await writeJsonFileAtomic(healthStatePath(), state);
|
||||||
|
return { record: next, toSend: pick };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toSend || !shouldSend(toSend, settings)) {
|
||||||
|
if (toSend) await clearUnsent(slug, toSend);
|
||||||
|
return { record };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, domain } = await deps.describe(slug);
|
||||||
|
const email = buildAlertEmail(toSend, label, domain);
|
||||||
|
const result = await deps.sendEmail(email.subject, email.text);
|
||||||
|
|
||||||
|
await withMutex("health-state", async () => {
|
||||||
|
const state = await readState();
|
||||||
|
const current = state.businesses[slug];
|
||||||
|
if (!current) return;
|
||||||
|
if (result.ok) {
|
||||||
|
current.lastAlertAt = now.toISOString();
|
||||||
|
current.unsentAlert = null;
|
||||||
|
} else {
|
||||||
|
current.unsentAlert = toSend;
|
||||||
|
}
|
||||||
|
await writeJsonFileAtomic(healthStatePath(), state);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { record, alertSent: result.ok ? toSend.type : undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearUnsent(slug: string, alert: HealthAlert): Promise<void> {
|
||||||
|
await withMutex("health-state", async () => {
|
||||||
|
const state = await readState();
|
||||||
|
const current = state.businesses[slug];
|
||||||
|
if (current?.unsentAlert?.raisedAt === alert.raisedAt) {
|
||||||
|
current.unsentAlert = null;
|
||||||
|
await writeJsonFileAtomic(healthStatePath(), state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* One probe of a business's own `GET /api/health` over the internal
|
||||||
|
* Docker network (the same `next-chair-<slug>:3000` host Caddy proxies
|
||||||
|
* to). The main app will later add optional `queue` / `cron` fields and a
|
||||||
|
* `status` of ok|degraded; they are consumed when present and, when
|
||||||
|
* absent, a reachable 200 is simply "healthy" — so this keeps working
|
||||||
|
* against an app image that predates them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface QueueHealth {
|
||||||
|
stalePendingCount: number | null;
|
||||||
|
oldestPendingAgeSeconds: number | null;
|
||||||
|
stuckProcessingCount: number | null;
|
||||||
|
failedLast24h: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HealthVerdict = "ok" | "degraded" | "down";
|
||||||
|
|
||||||
|
export interface HealthSnapshot {
|
||||||
|
verdict: HealthVerdict;
|
||||||
|
/** Human-readable reasons — empty when ok. Never includes response bodies verbatim. */
|
||||||
|
reasons: string[];
|
||||||
|
httpStatus: number | null;
|
||||||
|
installed: boolean | null;
|
||||||
|
installedAt: string | null;
|
||||||
|
lastCronRunAt: string | null;
|
||||||
|
queue: QueueHealth | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
function num(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure: turns a parsed /api/health body (or a failure) into a verdict. Exported for tests. */
|
||||||
|
export function classifyHealthBody(httpStatus: number, body: unknown): HealthSnapshot {
|
||||||
|
const base: HealthSnapshot = {
|
||||||
|
verdict: "ok",
|
||||||
|
reasons: [],
|
||||||
|
httpStatus,
|
||||||
|
installed: null,
|
||||||
|
installedAt: null,
|
||||||
|
lastCronRunAt: null,
|
||||||
|
queue: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (httpStatus !== 200) {
|
||||||
|
return { ...base, verdict: "down", reasons: [`/api/health returned HTTP ${httpStatus}`] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = record(body);
|
||||||
|
if (!obj) return { ...base, verdict: "down", reasons: ["/api/health did not return JSON"] };
|
||||||
|
|
||||||
|
const installed = typeof obj.installed === "boolean" ? obj.installed : null;
|
||||||
|
const cron = record(obj.cron);
|
||||||
|
const queueObj = record(obj.queue);
|
||||||
|
const queue: QueueHealth | null = queueObj
|
||||||
|
? {
|
||||||
|
stalePendingCount: num(queueObj.stalePendingCount ?? queueObj.stalePending),
|
||||||
|
oldestPendingAgeSeconds: num(queueObj.oldestPendingAgeSeconds ?? queueObj.oldestPendingAge),
|
||||||
|
stuckProcessingCount: num(queueObj.stuckProcessingCount ?? queueObj.stuckProcessing),
|
||||||
|
failedLast24h: num(queueObj.failedLast24h ?? queueObj.failed24h),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const reasons: string[] = [];
|
||||||
|
const rawReasons = obj.degradedReasons;
|
||||||
|
if (Array.isArray(rawReasons)) {
|
||||||
|
for (const reason of rawReasons) if (typeof reason === "string" && reason.trim()) reasons.push(reason.trim().slice(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = typeof obj.status === "string" ? obj.status.toLowerCase() : "";
|
||||||
|
// The current app reports "healthy"/"unhealthy"; the planned fields use ok|degraded.
|
||||||
|
const reportedDegraded = status === "degraded" || reasons.length > 0;
|
||||||
|
if (reportedDegraded && reasons.length === 0) reasons.push("the app reports itself degraded");
|
||||||
|
|
||||||
|
const verdict: HealthVerdict = status === "unhealthy" ? "down" : reportedDegraded ? "degraded" : "ok";
|
||||||
|
if (verdict === "down" && reasons.length === 0) reasons.push("the app reports itself unhealthy");
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
verdict,
|
||||||
|
reasons,
|
||||||
|
installed,
|
||||||
|
installedAt: str(obj.installedAt),
|
||||||
|
lastCronRunAt: str(cron?.lastCronRunAt) ?? str(obj.lastCronRunAt),
|
||||||
|
queue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchHealthSnapshot(slug: string): Promise<HealthSnapshot> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://next-chair-${slug}:3000/api/health`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
let body: unknown = null;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
return classifyHealthBody(response.status, body);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
verdict: "down",
|
||||||
|
reasons: ["couldn't reach the app (connection refused, DNS failure or timeout)"],
|
||||||
|
httpStatus: null,
|
||||||
|
installed: null,
|
||||||
|
installedAt: null,
|
||||||
|
lastCronRunAt: null,
|
||||||
|
queue: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WaitForHealthOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
intervalMs?: number;
|
||||||
|
/** Restore: the setup wizard must NOT appear, so wait for installed:true specifically. */
|
||||||
|
requireInstalled?: boolean;
|
||||||
|
onAttempt?: (snapshot: HealthSnapshot) => void;
|
||||||
|
probe?: (slug: string) => Promise<HealthSnapshot>;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls until the app answers 200 (and, if asked, reports installed:true). Throws a descriptive error on timeout. */
|
||||||
|
export async function waitForHealth(slug: string, options: WaitForHealthOptions = {}): Promise<HealthSnapshot> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3 * 60 * 1000;
|
||||||
|
const intervalMs = options.intervalMs ?? 3000;
|
||||||
|
const probe = options.probe ?? fetchHealthSnapshot;
|
||||||
|
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
let last: HealthSnapshot | null = null;
|
||||||
|
for (;;) {
|
||||||
|
last = await probe(slug);
|
||||||
|
options.onAttempt?.(last);
|
||||||
|
const reachable = last.httpStatus === 200;
|
||||||
|
if (reachable && (!options.requireInstalled || last.installed === true)) return last;
|
||||||
|
if (Date.now() >= deadline) break;
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last?.httpStatus === 200 && options.requireInstalled) {
|
||||||
|
throw new Error(`"${slug}" is up but reports installed:false — the setup wizard would appear. The restored database may be empty or wrong.`);
|
||||||
|
}
|
||||||
|
throw new Error(`"${slug}" did not become healthy within ${Math.round(timeoutMs / 1000)}s${last?.reasons.length ? ` (${last.reasons.join("; ")})` : ""}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Shared result shape for server actions that start a background job (kept out of the "use server" files, which may only export async functions). */
|
||||||
|
export interface JobStartState {
|
||||||
|
error: string | null;
|
||||||
|
jobId: string | null;
|
||||||
|
/** Plaintext backup passphrase — present only in the single response that just generated it. Shown once, never stored in a job record. */
|
||||||
|
newPassphrase: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_JOB: JobStartState = { error: null, jobId: null, newPassphrase: null };
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { writeJsonFileAtomic } from "./json-file";
|
||||||
|
import { acquireLock, LOCK_HEARTBEAT_MS, releaseLock, releaseLockIfOwnedBy, touchLock } from "./locks";
|
||||||
|
import { jobsDir } from "./manager-paths";
|
||||||
|
import { randomHex } from "./random";
|
||||||
|
import { globalSingleton, withMutex } from "./singleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background job runner. A job is started from a request (server action),
|
||||||
|
* runs on in this process after the request returns, and writes its whole
|
||||||
|
* state to STACKS_DIR/manager/jobs/<id>.json after every change — the UI
|
||||||
|
* polls GET /api/jobs/<id> and renders the steps and log tail. State in a
|
||||||
|
* file (not memory) so any server bundle can serve the poll, and a
|
||||||
|
* finished job's outcome survives a Manager restart.
|
||||||
|
*
|
||||||
|
* Every job holds the per-business lock (locks.ts) for its whole run, so
|
||||||
|
* Update / Backup / Restore / Delete can't overlap on one business.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type JobKind = "update" | "backup" | "restore" | "delete";
|
||||||
|
export type JobStatus = "running" | "succeeded" | "failed";
|
||||||
|
export type StepStatus = "pending" | "running" | "done" | "failed" | "skipped";
|
||||||
|
|
||||||
|
export interface JobStep {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: StepStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JobResult = Record<string, string | number | boolean | null>;
|
||||||
|
|
||||||
|
export interface JobRecord {
|
||||||
|
id: string;
|
||||||
|
kind: JobKind;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
status: JobStatus;
|
||||||
|
createdAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
steps: JobStep[];
|
||||||
|
logTail: string[];
|
||||||
|
error: string | null;
|
||||||
|
result: JobResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobContext {
|
||||||
|
jobId: string;
|
||||||
|
log(line: string): void;
|
||||||
|
/** Runs `fn` as the named step: marks it running, then done (or failed, rethrowing). */
|
||||||
|
step<T>(stepId: string, fn: () => Promise<T>): Promise<T>;
|
||||||
|
skip(stepId: string, detail?: string): void;
|
||||||
|
setResult(result: JobResult): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_TAIL_LINES = 200;
|
||||||
|
const LOG_LINE_MAX = 500;
|
||||||
|
const JOB_ID_PATTERN = /^[a-z0-9]{6,12}-[a-f0-9]{8}$/;
|
||||||
|
|
||||||
|
export function isValidJobId(id: string): boolean {
|
||||||
|
return JOB_ID_PATTERN.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobPath(id: string): string {
|
||||||
|
return path.join(jobsDir(), `${id}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ids of jobs running in THIS process — recovery must never "fail" a job that is genuinely still going. */
|
||||||
|
function runningJobIds(): Set<string> {
|
||||||
|
return globalSingleton("running_job_ids", () => new Set<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJob(id: string): Promise<JobRecord | null> {
|
||||||
|
if (!isValidJobId(id)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(jobPath(id), "utf8")) as JobRecord;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return message.length > 1000 ? `${message.slice(0, 1000)}…` : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartJobOptions {
|
||||||
|
kind: JobKind;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
steps: { id: string; label: string }[];
|
||||||
|
run: (ctx: JobContext) => Promise<JobResult | void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartedJob {
|
||||||
|
jobId: string;
|
||||||
|
/** Resolves (never rejects) with the final record once the job has finished. */
|
||||||
|
done: Promise<JobRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires the business lock (throws BusinessLockedError if busy), writes
|
||||||
|
* the initial record, and starts `run` in the background. The returned
|
||||||
|
* promise resolves as soon as the job is visible to pollers.
|
||||||
|
*/
|
||||||
|
export async function startJob(options: StartJobOptions): Promise<StartedJob> {
|
||||||
|
const id = `${Date.now().toString(36)}-${randomHex(4)}`;
|
||||||
|
await acquireLock(options.slug, { jobId: id, kind: options.kind });
|
||||||
|
|
||||||
|
const record: JobRecord = {
|
||||||
|
id,
|
||||||
|
kind: options.kind,
|
||||||
|
slug: options.slug,
|
||||||
|
title: options.title,
|
||||||
|
status: "running",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
finishedAt: null,
|
||||||
|
steps: options.steps.map((s) => ({ id: s.id, label: s.label, status: "pending", startedAt: null, finishedAt: null, detail: null })),
|
||||||
|
logTail: [],
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = () => withMutex(`job:${id}`, () => writeJsonFileAtomic(jobPath(id), record, 0o600));
|
||||||
|
try {
|
||||||
|
await persist();
|
||||||
|
} catch (error) {
|
||||||
|
await releaseLock(options.slug, id);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
runningJobIds().add(id);
|
||||||
|
|
||||||
|
const findStep = (stepId: string): JobStep => {
|
||||||
|
const step = record.steps.find((s) => s.id === stepId);
|
||||||
|
if (!step) throw new Error(`Unknown job step "${stepId}".`);
|
||||||
|
return step;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: JobContext = {
|
||||||
|
jobId: id,
|
||||||
|
log(line) {
|
||||||
|
const clean = line.replace(/\s+/g, " ").trim().slice(0, LOG_LINE_MAX);
|
||||||
|
if (!clean) return;
|
||||||
|
record.logTail.push(`${new Date().toISOString().slice(11, 19)} ${clean}`);
|
||||||
|
if (record.logTail.length > LOG_TAIL_LINES) record.logTail.splice(0, record.logTail.length - LOG_TAIL_LINES);
|
||||||
|
void persist().catch(() => undefined);
|
||||||
|
},
|
||||||
|
async step(stepId, fn) {
|
||||||
|
const step = findStep(stepId);
|
||||||
|
step.status = "running";
|
||||||
|
step.startedAt = new Date().toISOString();
|
||||||
|
await persist();
|
||||||
|
try {
|
||||||
|
const value = await fn();
|
||||||
|
step.status = "done";
|
||||||
|
step.finishedAt = new Date().toISOString();
|
||||||
|
await persist();
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
step.status = "failed";
|
||||||
|
step.finishedAt = new Date().toISOString();
|
||||||
|
step.detail = errorMessage(error);
|
||||||
|
await persist();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
skip(stepId, detail) {
|
||||||
|
const step = findStep(stepId);
|
||||||
|
step.status = "skipped";
|
||||||
|
step.detail = detail ?? null;
|
||||||
|
void persist().catch(() => undefined);
|
||||||
|
},
|
||||||
|
setResult(result) {
|
||||||
|
record.result = result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
void touchLock(options.slug, id).catch(() => undefined);
|
||||||
|
}, LOCK_HEARTBEAT_MS);
|
||||||
|
heartbeat.unref?.();
|
||||||
|
|
||||||
|
const done = (async (): Promise<JobRecord> => {
|
||||||
|
try {
|
||||||
|
const result = await options.run(ctx);
|
||||||
|
if (result) record.result = result;
|
||||||
|
record.status = "succeeded";
|
||||||
|
} catch (error) {
|
||||||
|
record.status = "failed";
|
||||||
|
record.error = errorMessage(error);
|
||||||
|
for (const step of record.steps) if (step.status === "pending") step.status = "skipped";
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
record.finishedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await persist();
|
||||||
|
} catch {
|
||||||
|
// The in-memory record is still returned to in-process callers.
|
||||||
|
}
|
||||||
|
runningJobIds().delete(id);
|
||||||
|
await releaseLock(options.slug, id).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { jobId: id, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time recovery: any job still "running" on disk when this process
|
||||||
|
* starts was killed with the previous process. Mark it failed so the UI
|
||||||
|
* stops spinning, and release its lock so the business isn't stuck busy
|
||||||
|
* until the stale-lock timeout.
|
||||||
|
*/
|
||||||
|
export async function recoverInterruptedJobs(): Promise<number> {
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(jobsDir());
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const interruptedBySlug = new Map<string, Set<string>>();
|
||||||
|
let recovered = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith(".json")) continue;
|
||||||
|
const id = file.slice(0, -".json".length);
|
||||||
|
if (runningJobIds().has(id)) continue;
|
||||||
|
const job = await readJob(id);
|
||||||
|
if (!job || job.status !== "running") continue;
|
||||||
|
job.status = "failed";
|
||||||
|
job.error = "Interrupted — the Manager restarted while this job was running. Check the business's state, then retry.";
|
||||||
|
job.finishedAt = new Date().toISOString();
|
||||||
|
for (const step of job.steps) {
|
||||||
|
if (step.status === "running") step.status = "failed";
|
||||||
|
else if (step.status === "pending") step.status = "skipped";
|
||||||
|
}
|
||||||
|
await writeJsonFileAtomic(jobPath(id), job, 0o600);
|
||||||
|
const set = interruptedBySlug.get(job.slug) ?? new Set<string>();
|
||||||
|
set.add(id);
|
||||||
|
interruptedBySlug.set(job.slug, set);
|
||||||
|
recovered += 1;
|
||||||
|
}
|
||||||
|
for (const [slug, ids] of interruptedBySlug) await releaseLockIfOwnedBy(slug, ids);
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes finished job records older than `maxAgeMs` (default 14 days). */
|
||||||
|
export async function pruneOldJobs(maxAgeMs = 14 * 24 * 60 * 60 * 1000, now: Date = new Date()): Promise<number> {
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(jobsDir());
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let removed = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith(".json")) continue;
|
||||||
|
const id = file.slice(0, -".json".length);
|
||||||
|
const job = await readJob(id);
|
||||||
|
if (!job || job.status === "running" || !job.finishedAt) continue;
|
||||||
|
if (now.getTime() - Date.parse(job.finishedAt) > maxAgeMs) {
|
||||||
|
await rm(jobPath(id), { force: true });
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { randomHex } from "./random";
|
||||||
|
|
||||||
|
/** Missing/corrupt file → the fallback, never a throw: these files are operational state, and a bad one must not take the Manager down. */
|
||||||
|
export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(filePath, "utf8")) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write-then-rename so a reader (or a crash mid-write) never sees a half-written file. */
|
||||||
|
export async function writeJsonFileAtomic(filePath: string, data: unknown, mode = 0o600): Promise<void> {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const tmpPath = `${filePath}.${randomHex(4)}.tmp`;
|
||||||
|
await writeFile(tmpPath, JSON.stringify(data, null, 2), { encoding: "utf8", mode });
|
||||||
|
await rename(tmpPath, filePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { mkdir, open, readFile, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { writeJsonFileAtomic } from "./json-file";
|
||||||
|
import { locksDir } from "./manager-paths";
|
||||||
|
import { withMutex } from "./singleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-business lock: at most one long-running operation (update, backup,
|
||||||
|
* restore, delete) per business at a time. A file under
|
||||||
|
* STACKS_DIR/manager/locks/<slug>.json, created with the exclusive-create
|
||||||
|
* flag so two acquirers can never both win.
|
||||||
|
*
|
||||||
|
* Stale-lock rule: the holder refreshes `heartbeatAt` every few seconds
|
||||||
|
* while it runs (see jobs.ts). A lock whose heartbeat is older than
|
||||||
|
* STALE_LOCK_MS belongs to a process that died (the Manager was
|
||||||
|
* restarted mid-job) and may be taken over. On boot the Manager also
|
||||||
|
* fails any job still marked "running" and releases its lock outright
|
||||||
|
* (jobs.ts → recoverInterruptedJobs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STALE_LOCK_MS = 2 * 60 * 1000;
|
||||||
|
export const LOCK_HEARTBEAT_MS = 15 * 1000;
|
||||||
|
|
||||||
|
export interface BusinessLock {
|
||||||
|
slug: string;
|
||||||
|
jobId: string;
|
||||||
|
kind: string;
|
||||||
|
acquiredAt: string;
|
||||||
|
heartbeatAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BusinessLockedError extends Error {
|
||||||
|
constructor(readonly lock: BusinessLock) {
|
||||||
|
super(`"${lock.slug}" is busy — a ${lock.kind} job (${lock.jobId}) is already running for it. Wait for it to finish.`);
|
||||||
|
this.name = "BusinessLockedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPath(slug: string): string {
|
||||||
|
return path.join(locksDir(), `${slug}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readLock(slug: string): Promise<BusinessLock | null> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(lockPath(slug), "utf8")) as BusinessLock;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLockStale(lock: BusinessLock, now: Date = new Date()): boolean {
|
||||||
|
const heartbeat = Date.parse(lock.heartbeatAt);
|
||||||
|
if (Number.isNaN(heartbeat)) return true;
|
||||||
|
return now.getTime() - heartbeat > STALE_LOCK_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live (non-stale) lock for a business, or null. */
|
||||||
|
export async function getActiveLock(slug: string, now: Date = new Date()): Promise<BusinessLock | null> {
|
||||||
|
const lock = await readLock(slug);
|
||||||
|
return lock && !isLockStale(lock, now) ? lock : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws BusinessLockedError if a live job holds the business — for quick actions (start/stop/restart) that must not run mid-update or mid-restore. */
|
||||||
|
export async function assertNotLocked(slug: string): Promise<void> {
|
||||||
|
const lock = await getActiveLock(slug);
|
||||||
|
if (lock) throw new BusinessLockedError(lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function acquireLock(slug: string, owner: { jobId: string; kind: string }, now: Date = new Date()): Promise<BusinessLock> {
|
||||||
|
// The mutex covers this process (separate bundles share it via globalThis);
|
||||||
|
// the exclusive-create flag below covers everything else.
|
||||||
|
return withMutex("business-locks", async () => {
|
||||||
|
await mkdir(locksDir(), { recursive: true });
|
||||||
|
const existing = await readLock(slug);
|
||||||
|
if (existing && !isLockStale(existing, now)) throw new BusinessLockedError(existing);
|
||||||
|
if (existing) await rm(lockPath(slug), { force: true });
|
||||||
|
|
||||||
|
const lock: BusinessLock = { slug, jobId: owner.jobId, kind: owner.kind, acquiredAt: now.toISOString(), heartbeatAt: now.toISOString() };
|
||||||
|
let handle;
|
||||||
|
try {
|
||||||
|
handle = await open(lockPath(slug), "wx", 0o600);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
||||||
|
const winner = await readLock(slug);
|
||||||
|
if (winner) throw new BusinessLockedError(winner);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await handle.writeFile(JSON.stringify(lock, null, 2), "utf8");
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refreshes the heartbeat — only if this job still owns the lock. */
|
||||||
|
export function touchLock(slug: string, jobId: string, now: Date = new Date()): Promise<void> {
|
||||||
|
return withMutex("business-locks", async () => {
|
||||||
|
const lock = await readLock(slug);
|
||||||
|
if (!lock || lock.jobId !== jobId) return;
|
||||||
|
await writeJsonFileAtomic(lockPath(slug), { ...lock, heartbeatAt: now.toISOString() });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the owner releases its lock — a job that was declared stale and taken over must not delete its successor's lock. */
|
||||||
|
export function releaseLock(slug: string, jobId: string): Promise<void> {
|
||||||
|
return withMutex("business-locks", async () => {
|
||||||
|
const lock = await readLock(slug);
|
||||||
|
if (lock && lock.jobId === jobId) await rm(lockPath(slug), { force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boot-time cleanup helper: drops the lock if (and only if) it belongs to one of the given interrupted jobs. */
|
||||||
|
export function releaseLockIfOwnedBy(slug: string, jobIds: Set<string>): Promise<void> {
|
||||||
|
return withMutex("business-locks", async () => {
|
||||||
|
const lock = await readLock(slug);
|
||||||
|
if (lock && jobIds.has(lock.jobId)) await rm(lockPath(slug), { force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { stacksRoot } from "./stacks";
|
||||||
|
|
||||||
|
/** Manager-owned state lives under STACKS_DIR/manager (already used by global-settings.json), so it survives container rebuilds with no extra volume. */
|
||||||
|
export function managerDataDir(): string {
|
||||||
|
return path.join(stacksRoot(), "manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jobsDir(): string {
|
||||||
|
return path.join(managerDataDir(), "jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function locksDir(): string {
|
||||||
|
return path.join(managerDataDir(), "locks");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schedulerStatePath(): string {
|
||||||
|
return path.join(managerDataDir(), "scheduler-state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthStatePath(): string {
|
||||||
|
return path.join(managerDataDir(), "health-state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupSettingsPath(): string {
|
||||||
|
return path.join(managerDataDir(), "backup-settings.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STACKS_DIR/backups/<slug>/ — deliberately outside the business's own stack directory, so deleting a business never deletes its backups. */
|
||||||
|
export function backupsRoot(): string {
|
||||||
|
return path.join(stacksRoot(), "backups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupDir(slug: string): string {
|
||||||
|
return path.join(backupsRoot(), slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uploaded .7z files waiting to be restored; swept after a day. */
|
||||||
|
export function uploadsDir(): string {
|
||||||
|
return path.join(backupsRoot(), "_uploads");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stagingRoot(): string {
|
||||||
|
return path.join(managerDataDir(), "tmp");
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { performBackup, BACKUP_STEPS } from "./backup";
|
||||||
|
import type { BackupKind } from "./backup-manifest";
|
||||||
|
import { recordBackupOutcome } from "./backup-settings";
|
||||||
|
import { composeUp } from "./docker";
|
||||||
|
import { readBackupPassphrase, readOperatorSettings } from "./global-settings";
|
||||||
|
import { setIntentionallyStopped } from "./health-watcher";
|
||||||
|
import { waitForHealth } from "./health";
|
||||||
|
import { startJob, type JobContext, type StartedJob } from "./jobs";
|
||||||
|
import { sendOperatorEmail } from "./operator-email";
|
||||||
|
import { deleteBusiness } from "./provisioning";
|
||||||
|
import { performRestore, readArchiveManifest, RESTORE_STEPS, resolveArchivePath, type RestoreSource } from "./restore";
|
||||||
|
import { isValidSlug, stackDir, stackExists } from "./stacks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The long-running operations, each as a background job holding the
|
||||||
|
* per-business lock (jobs.ts): Update, Backup, Restore, Delete. Server
|
||||||
|
* actions and the scheduler start them; the UI polls the job record.
|
||||||
|
* Everything that can fail partway reports it through the job — nothing
|
||||||
|
* here is awaited by a request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function requirePassphrase(): Promise<string> {
|
||||||
|
const passphrase = await readBackupPassphrase();
|
||||||
|
if (!passphrase) throw new Error("No backup passphrase exists yet — open a business's Backups page first so one is generated.");
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function alertBackupFailure(slug: string, message: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const settings = await readOperatorSettings();
|
||||||
|
if (!settings.alertOnBackupFailure || !settings.alertEmail) return;
|
||||||
|
await sendOperatorEmail(`Backup FAILED for ${slug}`, `The backup of "${slug}" failed.\n\nReason: ${message}\n\nCheck the Manager's Backups page for that business.\n`);
|
||||||
|
} catch {
|
||||||
|
// Alerting is best-effort; the failure is already in the job record and backup status.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackupJob(slug: string, kind: BackupKind = "regular"): Promise<StartedJob> {
|
||||||
|
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||||
|
await requirePassphrase();
|
||||||
|
|
||||||
|
return startJob({
|
||||||
|
kind: "backup",
|
||||||
|
slug,
|
||||||
|
title: `Back up ${slug}`,
|
||||||
|
steps: [...BACKUP_STEPS],
|
||||||
|
run: async (ctx) => {
|
||||||
|
try {
|
||||||
|
const result = await performBackup(slug, kind, ctx);
|
||||||
|
if (kind === "regular") await recordBackupOutcome(slug, { ok: true, message: `Saved ${result.fileName}` });
|
||||||
|
return { fileName: result.fileName, sizeBytes: result.sizeBytes, pruned: result.pruned.length };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (kind === "regular") {
|
||||||
|
await recordBackupOutcome(slug, { ok: false, message });
|
||||||
|
await alertBackupFailure(slug, message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recreates the container against the shared image tag; optionally takes a safety backup first. Rebuilding the image stays a manual step. */
|
||||||
|
export async function startUpdateJob(slug: string, backupFirst: boolean): Promise<StartedJob> {
|
||||||
|
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||||
|
if (backupFirst) await requirePassphrase();
|
||||||
|
|
||||||
|
return startJob({
|
||||||
|
kind: "update",
|
||||||
|
slug,
|
||||||
|
title: `Update ${slug}`,
|
||||||
|
steps: [
|
||||||
|
...(backupFirst ? [{ id: "backup", label: "Back up first" }] : []),
|
||||||
|
{ id: "recreate", label: "Recreate the container" },
|
||||||
|
{ id: "health", label: "Wait for /api/health" },
|
||||||
|
{ id: "done", label: "Done" },
|
||||||
|
],
|
||||||
|
run: async (ctx) => {
|
||||||
|
if (backupFirst) {
|
||||||
|
await ctx.step("backup", async () => {
|
||||||
|
const result = await performBackup(slug, "safety", { log: ctx.log });
|
||||||
|
ctx.log(`Pre-update backup: ${result.fileName}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await ctx.step("recreate", async () => {
|
||||||
|
ctx.log("docker compose up -d --force-recreate");
|
||||||
|
await composeUp(stackDir(slug), { forceRecreate: true });
|
||||||
|
});
|
||||||
|
await ctx.step("health", async () => {
|
||||||
|
let lastSeen = "";
|
||||||
|
await waitForHealth(slug, {
|
||||||
|
onAttempt: (s) => {
|
||||||
|
const seen = `HTTP ${s.httpStatus ?? "no response"}`;
|
||||||
|
if (seen !== lastSeen) ctx.log(`health: ${seen}`);
|
||||||
|
lastSeen = seen;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await ctx.step("done", async () => {
|
||||||
|
ctx.log("The container is running the current shared image and answering /api/health.");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes an optional final backup, then removes the business. If the backup fails the business is left untouched. */
|
||||||
|
export async function startDeleteJob(slug: string, finalBackup: boolean): Promise<StartedJob> {
|
||||||
|
if (!isValidSlug(slug) || !(await stackExists(slug))) throw new Error(`No such business: ${slug}`);
|
||||||
|
if (finalBackup) await requirePassphrase();
|
||||||
|
|
||||||
|
return startJob({
|
||||||
|
kind: "delete",
|
||||||
|
slug,
|
||||||
|
title: `Delete ${slug}`,
|
||||||
|
steps: [
|
||||||
|
{ id: "final-backup", label: "Take a final backup" },
|
||||||
|
{ id: "delete", label: "Remove the business" },
|
||||||
|
],
|
||||||
|
run: async (ctx: JobContext) => {
|
||||||
|
let finalName: string | null = null;
|
||||||
|
if (finalBackup) {
|
||||||
|
finalName = await ctx.step("final-backup", async () => (await performBackup(slug, "final", { log: ctx.log })).fileName);
|
||||||
|
} else {
|
||||||
|
ctx.skip("final-backup", "Skipped by the operator.");
|
||||||
|
}
|
||||||
|
const result = await ctx.step("delete", async () => deleteBusiness(slug));
|
||||||
|
await setIntentionallyStopped(slug, false);
|
||||||
|
return { finalBackup: finalName, warnings: result.warnings.join("; ") || null };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreJobInput {
|
||||||
|
source: RestoreSource;
|
||||||
|
/** Blank → the stored passphrase. */
|
||||||
|
passphrase: string;
|
||||||
|
replace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startRestoreJob(input: RestoreJobInput): Promise<StartedJob> {
|
||||||
|
const passphrase = input.passphrase.trim() || (await readBackupPassphrase()) || "";
|
||||||
|
if (!passphrase) throw new Error("Enter the backup passphrase.");
|
||||||
|
const archivePath = resolveArchivePath(input.source);
|
||||||
|
|
||||||
|
// The lock is per slug and the slug lives in the archive, so read the manifest first (fast: only the encrypted header is opened).
|
||||||
|
const manifest = await readArchiveManifest(archivePath, passphrase);
|
||||||
|
|
||||||
|
return startJob({
|
||||||
|
kind: "restore",
|
||||||
|
slug: manifest.slug,
|
||||||
|
title: `Restore ${manifest.slug}`,
|
||||||
|
steps: [...RESTORE_STEPS],
|
||||||
|
run: async (ctx) => {
|
||||||
|
const result = await performRestore({ archivePath, passphrase, replace: input.replace, ctx });
|
||||||
|
await setIntentionallyStopped(result.slug, false);
|
||||||
|
return { slug: result.slug, domain: result.domain, safetyBackup: result.safetyBackup };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { isEmailConfigComplete, readOperatorSettings, resolveEmailSettings } from "./global-settings";
|
||||||
|
import { sendPlainSmtpEmail } from "./test-email";
|
||||||
|
|
||||||
|
export interface OperatorEmailResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emails the operator alert address using the Manager's existing SMTP
|
||||||
|
* settings (Settings > Email) — the same pattern as the Settings page's
|
||||||
|
* test email. Never throws; a missing address or incomplete SMTP config
|
||||||
|
* is reported as `ok: false` so the caller can retry later.
|
||||||
|
*/
|
||||||
|
export async function sendOperatorEmail(subject: string, text: string): Promise<OperatorEmailResult> {
|
||||||
|
const operator = await readOperatorSettings();
|
||||||
|
if (!operator.alertEmail) return { ok: false, message: "No operator alert email is configured." };
|
||||||
|
|
||||||
|
let smtp;
|
||||||
|
try {
|
||||||
|
smtp = await resolveEmailSettings();
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: "Couldn't read the SMTP settings (is MANAGER_SETTINGS_ENCRYPTION_KEY set?)." };
|
||||||
|
}
|
||||||
|
if (!smtp || !isEmailConfigComplete(smtp)) return { ok: false, message: "SMTP isn't fully configured in Settings > Email." };
|
||||||
|
|
||||||
|
return sendPlainSmtpEmail({ ...smtp, recipient: operator.alertEmail, subject: `[NextChair] ${subject}`, text });
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { createReadStream, createWriteStream } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming subprocess helpers. Everything big (database dumps, media
|
||||||
|
* tars, archives) flows through spawn + pipes straight to/from disk —
|
||||||
|
* never execFile, whose maxBuffer would kill a large dump mid-stream.
|
||||||
|
*
|
||||||
|
* Commands are always `{ command, args[] }` (never a shell string), and
|
||||||
|
* secrets travel in `env` (e.g. MYSQL_PWD) or the 7zz passphrase arg —
|
||||||
|
* never in anything this module logs: errors carry only the command's
|
||||||
|
* basename and its stderr tail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CommandSpec {
|
||||||
|
command: string;
|
||||||
|
args: string[];
|
||||||
|
/** Merged over process.env. */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
cwd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommandError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly exitCode: number | null,
|
||||||
|
readonly stderrTail: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CommandError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STDERR_TAIL_BYTES = 4000;
|
||||||
|
|
||||||
|
function spawnSpec(spec: CommandSpec, stdio: ("pipe" | "ignore")[]): ChildProcess {
|
||||||
|
return spawn(spec.command, spec.args, {
|
||||||
|
cwd: spec.cwd,
|
||||||
|
env: { ...process.env, ...spec.env },
|
||||||
|
stdio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Completion {
|
||||||
|
done: Promise<void>;
|
||||||
|
stderrTail: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves on a clean exit; rejects (with the stderr tail) on spawn failure or non-zero exit. */
|
||||||
|
function watch(child: ChildProcess, spec: CommandSpec): Completion {
|
||||||
|
let tail = "";
|
||||||
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
tail = (tail + chunk.toString("utf8")).slice(-STDERR_TAIL_BYTES);
|
||||||
|
});
|
||||||
|
const done = new Promise<void>((resolve, reject) => {
|
||||||
|
child.once("error", (error) => {
|
||||||
|
reject(new CommandError(`Couldn't run ${path.basename(spec.command)}: ${error.message}`, null, tail));
|
||||||
|
});
|
||||||
|
child.once("close", (code, signal) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else {
|
||||||
|
const reason = signal ? `was killed (${signal})` : `exited with code ${code}`;
|
||||||
|
reject(new CommandError(`${path.basename(spec.command)} ${reason}${tail.trim() ? `: ${tail.trim()}` : ""}`, code, tail));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { done, stderrTail: () => tail };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a command and returns its (size-capped) stdout — for SMALL output only (a version string, a one-row query). */
|
||||||
|
export async function runCapture(
|
||||||
|
spec: CommandSpec,
|
||||||
|
options: { maxBytes?: number; timeoutMs?: number } = {},
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const maxBytes = options.maxBytes ?? 1024 * 1024;
|
||||||
|
const child = spawnSpec(spec, ["ignore", "pipe", "pipe"]);
|
||||||
|
const { done, stderrTail } = watch(child, spec);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
child.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > maxBytes) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
const timer = options.timeoutMs ? setTimeout(() => child.kill("SIGKILL"), options.timeoutMs) : null;
|
||||||
|
try {
|
||||||
|
await done;
|
||||||
|
} catch (error) {
|
||||||
|
// We killed it for exceeding the cap — say that, not the resulting SIGKILL.
|
||||||
|
if (size > maxBytes) throw new CommandError(`${path.basename(spec.command)} produced more than ${maxBytes} bytes of output.`, null, "");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
if (size > maxBytes) throw new CommandError(`${path.basename(spec.command)} produced more than ${maxBytes} bytes of output.`, null, "");
|
||||||
|
return { stdout: Buffer.concat(chunks).toString("utf8"), stderr: stderrTail() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a command that needs no output captured at all (e.g. `7zz a`/`7zz t`) — only the stderr tail is kept for error reporting. */
|
||||||
|
export async function runQuiet(spec: CommandSpec): Promise<void> {
|
||||||
|
const child = spawnSpec(spec, ["ignore", "ignore", "pipe"]);
|
||||||
|
await watch(child, spec).done;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stdout → file, streamed. The file is written with 0600 permissions. */
|
||||||
|
export async function runToFile(spec: CommandSpec, filePath: string): Promise<void> {
|
||||||
|
const child = spawnSpec(spec, ["ignore", "pipe", "pipe"]);
|
||||||
|
const { done } = watch(child, spec);
|
||||||
|
// Attach the failure handler before awaiting the pipeline so a fast
|
||||||
|
// non-zero exit can't surface as an unhandled rejection.
|
||||||
|
const settled = done.then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await pipeline(child.stdout!, createWriteStream(filePath, { mode: 0o600 }));
|
||||||
|
} catch (error) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
throw (await settled) ?? error;
|
||||||
|
}
|
||||||
|
const failure = await settled;
|
||||||
|
if (failure) throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** file → stdin, streamed. */
|
||||||
|
export async function runFromFile(spec: CommandSpec, filePath: string): Promise<void> {
|
||||||
|
const child = spawnSpec(spec, ["pipe", "ignore", "pipe"]);
|
||||||
|
const { done } = watch(child, spec);
|
||||||
|
const settled = done.then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
child.stdin?.on("error", () => undefined); // EPIPE when the command exits early — its exit code tells the real story
|
||||||
|
try {
|
||||||
|
await pipeline(createReadStream(filePath), child.stdin!);
|
||||||
|
} catch (error) {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
throw (await settled) ?? error;
|
||||||
|
}
|
||||||
|
const failure = await settled;
|
||||||
|
if (failure) throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** source.stdout → sink.stdin (e.g. `7zz x -so … | docker exec -i … mysql`). Fails if either side fails; the failing side's stderr is reported. */
|
||||||
|
export async function runPipeline(source: CommandSpec, sink: CommandSpec): Promise<void> {
|
||||||
|
const producer = spawnSpec(source, ["ignore", "pipe", "pipe"]);
|
||||||
|
const consumer = spawnSpec(sink, ["pipe", "ignore", "pipe"]);
|
||||||
|
const producerWatch = watch(producer, source);
|
||||||
|
const consumerWatch = watch(consumer, sink);
|
||||||
|
const producerResult = producerWatch.done.then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
const consumerResult = consumerWatch.done.then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
consumer.stdin?.on("error", () => undefined);
|
||||||
|
producer.stdout?.pipe(consumer.stdin!);
|
||||||
|
|
||||||
|
const [producerError, consumerError] = await Promise.all([producerResult, consumerResult]);
|
||||||
|
// If the producer died, the consumer saw a truncated stream and may
|
||||||
|
// have "succeeded" on it — the producer's failure is the root cause.
|
||||||
|
if (producerError || consumerError) {
|
||||||
|
producer.kill("SIGKILL");
|
||||||
|
consumer.kill("SIGKILL");
|
||||||
|
// When both fail, either could be the root cause (a dead consumer
|
||||||
|
// gives the producer an EPIPE; a dead producer hands the consumer a
|
||||||
|
// truncated stream) — report both rather than guess.
|
||||||
|
const messages = [producerError, consumerError].filter(Boolean).map((e) => (e instanceof Error ? e.message : String(e)));
|
||||||
|
throw new CommandError(messages.join(" | "), null, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ function assertValidDomain(domain: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertDomainAvailable(domain: string, excludeSlug?: string): Promise<void> {
|
export async function assertDomainAvailable(domain: string, excludeSlug?: string): Promise<void> {
|
||||||
const existing = await listBusinessStacks();
|
const existing = await listBusinessStacks();
|
||||||
const collision = existing.find((s) => s.slug !== excludeSlug && s.domain === domain);
|
const collision = existing.find((s) => s.slug !== excludeSlug && s.domain === domain);
|
||||||
if (collision) {
|
if (collision) {
|
||||||
@@ -44,7 +44,7 @@ async function assertDomainAvailable(domain: string, excludeSlug?: string): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeCaddySiteAndReload(slug: string, domain: string): Promise<void> {
|
export async function writeCaddySiteAndReload(slug: string, domain: string): Promise<void> {
|
||||||
const sitesDir = path.join(stacksRoot(), "infra", "sites");
|
const sitesDir = path.join(stacksRoot(), "infra", "sites");
|
||||||
await mkdir(sitesDir, { recursive: true });
|
await mkdir(sitesDir, { recursive: true });
|
||||||
await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8");
|
await writeFile(path.join(sitesDir, `${slug}.caddy`), generateCaddySite(slug, domain), "utf8");
|
||||||
@@ -73,7 +73,7 @@ async function connectAsRoot(): Promise<Connection> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
export async function createDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||||
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
throw new Error("Refusing to create a database/user from an unsafe identifier.");
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ async function createDatabaseAndUser(databaseName: string, databaseUser: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Irreversible — only ever called from deleteBusiness, itself gated behind a type-the-slug confirmation in the UI. */
|
/** Irreversible — only ever called from deleteBusiness, itself gated behind a type-the-slug confirmation in the UI. */
|
||||||
async function dropDatabaseAndUser(databaseName: string, databaseUser: string): Promise<void> {
|
export async function dropDatabaseAndUser(databaseName: string, databaseUser: string): Promise<void> {
|
||||||
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
if (!SAFE_DB_IDENTIFIER.test(databaseName) || !SAFE_DB_IDENTIFIER.test(databaseUser)) {
|
||||||
throw new Error("Refusing to drop a database/user from an unsafe identifier.");
|
throw new Error("Refusing to drop a database/user from an unsafe identifier.");
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,19 @@ async function dropDatabaseAndUser(databaseName: string, databaseUser: string):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore path: makes the database + user exist, empty, with the password
|
||||||
|
* that is already written in the (backed-up) .env — dropping whatever was
|
||||||
|
* there first, since a replacing restore starts from a clean database.
|
||||||
|
* Only the explicit-password overload of createDatabaseAndUser is used
|
||||||
|
* here, never a freshly generated one: the restored .env must keep
|
||||||
|
* working as-is.
|
||||||
|
*/
|
||||||
|
export async function recreateDatabaseAndUser(databaseName: string, databaseUser: string, databasePassword: string): Promise<void> {
|
||||||
|
await dropDatabaseAndUser(databaseName, databaseUser);
|
||||||
|
await createDatabaseAndUser(databaseName, databaseUser, databasePassword);
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProvisionBusinessInput {
|
export interface ProvisionBusinessInput {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { performBackup } from "./backup";
|
||||||
|
import { BACKUP_MEMBERS, checkMigrationCompatibility, parseManifest, type BackupManifest } from "./backup-manifest";
|
||||||
|
import { backupFilePath } from "./backup-store";
|
||||||
|
import { generateComposeYaml } from "./compose-template";
|
||||||
|
import { composeCreate, composeDown, composeUp, listImageMigrations } from "./docker";
|
||||||
|
import { mediaRestoreCommand, mysqlImportCommand } from "./docker-commands";
|
||||||
|
import { readEnvValue } from "./env-file";
|
||||||
|
import { waitForHealth } from "./health";
|
||||||
|
import { uploadsDir } from "./manager-paths";
|
||||||
|
import { assertDomainAvailable, recreateDatabaseAndUser, writeCaddySiteAndReload } from "./provisioning";
|
||||||
|
import { runPipeline } from "./process";
|
||||||
|
import { sevenZipExtractToStdoutCommand, sevenZipReadMember, sevenZipTest } from "./sevenzip";
|
||||||
|
import { stackDir, stackExists } from "./stacks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a backup archive as a business — onto an existing business
|
||||||
|
* (replace) or onto a fresh server where it never existed. The step order
|
||||||
|
* is the contract (and is asserted in tests):
|
||||||
|
*
|
||||||
|
* verify → check → safety backup (replace only) → stop → recreate DB
|
||||||
|
* → import dump → write config + Caddy → restore media → start → health
|
||||||
|
*
|
||||||
|
* The database password comes from the backed-up .env (so that .env keeps
|
||||||
|
* working untouched); the dump already contains the "installed" row, so
|
||||||
|
* the setup wizard doesn't appear — the final step insists on
|
||||||
|
* `installed: true`. Like performBackup this does not take the business
|
||||||
|
* lock; the restore job holds it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const RESTORE_STEPS = [
|
||||||
|
{ id: "verify", label: "Verify the archive and passphrase" },
|
||||||
|
{ id: "check", label: "Check for conflicts and schema version" },
|
||||||
|
{ id: "safety", label: "Take a safety backup of the current business" },
|
||||||
|
{ id: "stop", label: "Stop the current stack" },
|
||||||
|
{ id: "database", label: "Recreate the database and user" },
|
||||||
|
{ id: "import", label: "Import the database dump" },
|
||||||
|
{ id: "config", label: "Write configuration and the Caddy site" },
|
||||||
|
{ id: "media", label: "Restore media files" },
|
||||||
|
{ id: "start", label: "Start the stack" },
|
||||||
|
{ id: "health", label: "Wait for the app to report installed" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RestoreSource = { kind: "backup"; slug: string; fileName: string } | { kind: "upload"; uploadId: string };
|
||||||
|
|
||||||
|
const UPLOAD_ID_PATTERN = /^[a-f0-9]{16}$/;
|
||||||
|
|
||||||
|
export function resolveArchivePath(source: RestoreSource): string {
|
||||||
|
if (source.kind === "backup") return backupFilePath(source.slug, source.fileName);
|
||||||
|
if (!UPLOAD_ID_PATTERN.test(source.uploadId)) throw new Error("Invalid upload reference.");
|
||||||
|
return path.join(uploadsDir(), `${source.uploadId}.7z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestorePlan {
|
||||||
|
manifest: BackupManifest;
|
||||||
|
/** A stack with this slug already exists — restoring needs explicit "replace". */
|
||||||
|
slugExists: boolean;
|
||||||
|
/** Another business already uses the backup's domain. */
|
||||||
|
domainConflict: string | null;
|
||||||
|
schema: { ok: true } | { ok: false; reason: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fast: opens only the (encrypted) header and reads manifest.json. A wrong passphrase fails here. Does not verify the payload. */
|
||||||
|
export async function readArchiveManifest(archivePath: string, passphrase: string): Promise<BackupManifest> {
|
||||||
|
return parseManifest(await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.manifest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full verification (`7zz t` over the whole archive) plus the manifest. Throws on a corrupt archive or wrong passphrase. */
|
||||||
|
export async function inspectArchive(archivePath: string, passphrase: string): Promise<BackupManifest> {
|
||||||
|
await sevenZipTest(archivePath, passphrase);
|
||||||
|
return readArchiveManifest(archivePath, passphrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only conflict/version report shown on the Restore page before the operator confirms. */
|
||||||
|
export async function planRestore(manifest: BackupManifest): Promise<RestorePlan> {
|
||||||
|
const slugExists = await stackExists(manifest.slug);
|
||||||
|
let domainConflict: string | null = null;
|
||||||
|
try {
|
||||||
|
await assertDomainAvailable(manifest.domain, manifest.slug);
|
||||||
|
} catch (error) {
|
||||||
|
domainConflict = error instanceof Error ? error.message : "Domain conflict.";
|
||||||
|
}
|
||||||
|
let schema: RestorePlan["schema"];
|
||||||
|
try {
|
||||||
|
schema = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations());
|
||||||
|
} catch (error) {
|
||||||
|
schema = { ok: false, reason: `Couldn't read the app image's migrations: ${error instanceof Error ? error.message : String(error)}` };
|
||||||
|
}
|
||||||
|
return { manifest, slugExists, domainConflict, schema };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreContext {
|
||||||
|
log(line: string): void;
|
||||||
|
step<T>(stepId: string, fn: () => Promise<T>): Promise<T>;
|
||||||
|
skip(stepId: string, detail?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreResult {
|
||||||
|
slug: string;
|
||||||
|
domain: string;
|
||||||
|
safetyBackup: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performRestore(params: {
|
||||||
|
archivePath: string;
|
||||||
|
passphrase: string;
|
||||||
|
/** Operator confirmed overwriting an existing business with the same slug. */
|
||||||
|
replace: boolean;
|
||||||
|
ctx: RestoreContext;
|
||||||
|
}): Promise<RestoreResult> {
|
||||||
|
const { archivePath, passphrase, replace, ctx } = params;
|
||||||
|
|
||||||
|
const { manifest, envContent } = await ctx.step("verify", async () => {
|
||||||
|
ctx.log("Testing the archive (7zz t)…");
|
||||||
|
const parsed = await inspectArchive(archivePath, passphrase);
|
||||||
|
const env = await sevenZipReadMember(archivePath, passphrase, BACKUP_MEMBERS.env);
|
||||||
|
ctx.log(`Backup of "${parsed.slug}" (${parsed.domain}) taken ${parsed.createdAt}.`);
|
||||||
|
|
||||||
|
// The .env inside the archive must agree with the manifest — the
|
||||||
|
// password for the new database user comes from it.
|
||||||
|
const envDb = readEnvValue(env, "DATABASE_NAME");
|
||||||
|
const envUser = readEnvValue(env, "DATABASE_USER");
|
||||||
|
const envPassword = readEnvValue(env, "DATABASE_PASSWORD");
|
||||||
|
if (envDb !== parsed.databaseName || envUser !== parsed.databaseUser || !envPassword) {
|
||||||
|
throw new Error("The archive's .env doesn't match its manifest (database name/user/password) — refusing to restore it.");
|
||||||
|
}
|
||||||
|
return { manifest: parsed, envContent: env };
|
||||||
|
});
|
||||||
|
|
||||||
|
const slug = manifest.slug;
|
||||||
|
const dir = stackDir(slug);
|
||||||
|
const exists = await stackExists(slug);
|
||||||
|
|
||||||
|
await ctx.step("check", async () => {
|
||||||
|
if (exists && !replace) {
|
||||||
|
throw new Error(`A business named "${slug}" already exists. Confirm "replace" to overwrite it (a safety backup is taken first).`);
|
||||||
|
}
|
||||||
|
await assertDomainAvailable(manifest.domain, slug);
|
||||||
|
const compatibility = checkMigrationCompatibility(manifest.lastMigration, await listImageMigrations());
|
||||||
|
if (!compatibility.ok) throw new Error(compatibility.reason);
|
||||||
|
ctx.log("No conflicts; the backup's schema is compatible with the running app image.");
|
||||||
|
});
|
||||||
|
|
||||||
|
let safetyBackup: string | null = null;
|
||||||
|
if (exists) {
|
||||||
|
safetyBackup = await ctx.step("safety", async () => {
|
||||||
|
ctx.log(`"${slug}" exists — taking a safety backup before replacing it…`);
|
||||||
|
const result = await performBackup(slug, "safety", { log: ctx.log });
|
||||||
|
ctx.log(`Safety backup: ${result.fileName}`);
|
||||||
|
return result.fileName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ctx.skip("safety", "No existing business to protect.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
await ctx.step("stop", async () => {
|
||||||
|
// -v: the old media volume goes too, so files deleted since the backup don't linger.
|
||||||
|
await composeDown(dir, { removeVolumes: true });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ctx.skip("stop", "Nothing running under this slug.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.step("database", async () => {
|
||||||
|
await recreateDatabaseAndUser(manifest.databaseName, manifest.databaseUser, readEnvValue(envContent, "DATABASE_PASSWORD")!);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.step("import", async () => {
|
||||||
|
ctx.log("Importing db.sql (streamed from the archive)…");
|
||||||
|
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.database), mysqlImportCommand(manifest.databaseName));
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.step("config", async () => {
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(path.join(dir, ".env"), envContent, { encoding: "utf8", mode: 0o600 });
|
||||||
|
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
|
||||||
|
await writeCaddySiteAndReload(slug, manifest.domain);
|
||||||
|
ctx.log("Wrote .env and compose.yaml, and reloaded Caddy.");
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.step("media", async () => {
|
||||||
|
// Create (don't start) the container so the named volume exists with compose's labels before it's filled.
|
||||||
|
await composeCreate(dir);
|
||||||
|
await runPipeline(sevenZipExtractToStdoutCommand(archivePath, passphrase, BACKUP_MEMBERS.media), mediaRestoreCommand(slug));
|
||||||
|
ctx.log("Media restored and handed to uid 1001.");
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.step("start", async () => {
|
||||||
|
// The entrypoint runs `prisma migrate deploy`, so an older dump upgrades itself here.
|
||||||
|
await composeUp(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.step("health", async () => {
|
||||||
|
// Log only when the answer changes — polling every few seconds would otherwise bury the log tail.
|
||||||
|
let lastSeen = "";
|
||||||
|
await waitForHealth(slug, {
|
||||||
|
requireInstalled: true,
|
||||||
|
onAttempt: (s) => {
|
||||||
|
const seen = `HTTP ${s.httpStatus ?? "no response"}, installed=${String(s.installed)}`;
|
||||||
|
if (seen !== lastSeen) ctx.log(`health: ${seen}`);
|
||||||
|
lastSeen = seen;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { slug, domain: manifest.domain, safetyBackup };
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { readJsonFile, writeJsonFileAtomic } from "./json-file";
|
||||||
|
import { recoverInterruptedJobs } from "./jobs";
|
||||||
|
import { readOperatorSettings } from "./global-settings";
|
||||||
|
import { schedulerStatePath } from "./manager-paths";
|
||||||
|
import {
|
||||||
|
emptyState,
|
||||||
|
runTick,
|
||||||
|
type SchedulerState,
|
||||||
|
type SchedulerStateStore,
|
||||||
|
type TaskState,
|
||||||
|
} from "./scheduler";
|
||||||
|
import { buildSchedulerTasks } from "./scheduler-tasks";
|
||||||
|
import { globalSingleton } from "./singleton";
|
||||||
|
import { listStackSlugs } from "./stacks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the one scheduler interval for this Manager process. Called from
|
||||||
|
* instrumentation.ts register(); guarded by a globalThis singleton so dev
|
||||||
|
* hot-reloads (or an accidental second register()) can never run two.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TICK_MS = 5000;
|
||||||
|
|
||||||
|
class FileStateStore implements SchedulerStateStore {
|
||||||
|
private writing = false;
|
||||||
|
private dirty = false;
|
||||||
|
|
||||||
|
constructor(private state: SchedulerState) {}
|
||||||
|
|
||||||
|
get(): SchedulerState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
patch(key: string, patch: Partial<TaskState>): void {
|
||||||
|
const current = this.state.tasks[key] ?? { lastStartedAt: null, lastFinishedAt: null, lastOk: null, lastMessage: null };
|
||||||
|
this.state.tasks[key] = { ...current, ...patch };
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
setHeartbeat(at: Date): void {
|
||||||
|
this.state.heartbeatAt = at.toISOString();
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
retain(predicate: (key: string) => boolean): void {
|
||||||
|
for (const key of Object.keys(this.state.tasks)) {
|
||||||
|
if (!predicate(key)) {
|
||||||
|
delete this.state.tasks[key];
|
||||||
|
this.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async flush(): Promise<void> {
|
||||||
|
if (this.writing) return; // the in-flight writer loops until clean
|
||||||
|
this.writing = true;
|
||||||
|
try {
|
||||||
|
while (this.dirty) {
|
||||||
|
this.dirty = false;
|
||||||
|
await writeJsonFileAtomic(schedulerStatePath(), this.state);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.writing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SchedulerHandle {
|
||||||
|
timer: ReturnType<typeof setInterval>;
|
||||||
|
store: FileStateStore;
|
||||||
|
ticking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startScheduler(): Promise<boolean> {
|
||||||
|
const holder = globalSingleton<{ handle: SchedulerHandle | null; starting: boolean }>("scheduler_handle", () => ({ handle: null, starting: false }));
|
||||||
|
if (holder.handle || holder.starting) return false;
|
||||||
|
holder.starting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recovered = await recoverInterruptedJobs().catch(() => 0);
|
||||||
|
if (recovered > 0) console.warn(`[scheduler] marked ${recovered} interrupted job(s) as failed.`);
|
||||||
|
|
||||||
|
const loaded = await readJsonFile<SchedulerState>(schedulerStatePath(), emptyState());
|
||||||
|
const store = new FileStateStore({ ...emptyState(), ...loaded, tasks: loaded.tasks ?? {} });
|
||||||
|
const tasks = buildSchedulerTasks();
|
||||||
|
|
||||||
|
const handle: SchedulerHandle = { store, ticking: false, timer: setInterval(() => void tick(), TICK_MS) };
|
||||||
|
handle.timer.unref?.();
|
||||||
|
holder.handle = handle;
|
||||||
|
|
||||||
|
async function tick(): Promise<void> {
|
||||||
|
if (handle.ticking) return; // a previous tick is still dispatching (slow disk/list)
|
||||||
|
handle.ticking = true;
|
||||||
|
try {
|
||||||
|
// Deliberately not awaiting `done`: a long backup must not stall the next dispatch.
|
||||||
|
await runTick({
|
||||||
|
tasks,
|
||||||
|
store,
|
||||||
|
listSlugs: listStackSlugs,
|
||||||
|
config: readOperatorSettings,
|
||||||
|
now: () => new Date(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[scheduler] tick failed:", error instanceof Error ? error.message : error);
|
||||||
|
} finally {
|
||||||
|
handle.ticking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[scheduler] started (${tasks.map((t) => t.id).join(", ")}).`);
|
||||||
|
setTimeout(() => void tick(), 3000).unref?.();
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
holder.starting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readJsonFile } from "./json-file";
|
||||||
|
import { schedulerStatePath } from "./manager-paths";
|
||||||
|
import { emptyState, type SchedulerState, type TaskState } from "./scheduler";
|
||||||
|
|
||||||
|
export interface SchedulerStatusRow {
|
||||||
|
key: string;
|
||||||
|
state: TaskState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerStatus {
|
||||||
|
heartbeatAt: string | null;
|
||||||
|
rows: SchedulerStatusRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only view of scheduler-state.json for the Settings page — failures first. */
|
||||||
|
export async function readSchedulerStatus(): Promise<SchedulerStatus> {
|
||||||
|
const state = await readJsonFile<SchedulerState>(schedulerStatePath(), emptyState());
|
||||||
|
const rows = Object.entries(state.tasks ?? {}).map(([key, taskState]) => ({ key, state: taskState }));
|
||||||
|
rows.sort((a, b) => Number(a.state.lastOk === true) - Number(b.state.lastOk === true) || a.key.localeCompare(b.key));
|
||||||
|
return { heartbeatAt: state.heartbeatAt ?? null, rows };
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { readdir, rm, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { isBackupDue, readBackupSettings, recordBackupAttempt } from "./backup-settings";
|
||||||
|
import { readEnvFile, readEnvValue } from "./env-file";
|
||||||
|
import { runHealthCheck } from "./health-watcher";
|
||||||
|
import { pruneOldJobs } from "./jobs";
|
||||||
|
import { BusinessLockedError } from "./locks";
|
||||||
|
import { stagingRoot, uploadsDir } from "./manager-paths";
|
||||||
|
import { startBackupJob } from "./operations";
|
||||||
|
import type { SchedulerTask, TaskResult } from "./scheduler";
|
||||||
|
import { stackDir } from "./stacks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tasks the built-in scheduler runs. They replace the host crontab
|
||||||
|
* calling stacks/infra/scripts/process-cron.sh (kept documented as a
|
||||||
|
* fallback), and add health checks, scheduled backups and housekeeping.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NOTIFICATIONS_TIMEOUT_MS = 25_000;
|
||||||
|
const REVIEWS_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** POSTs to a business's CRON_SECRET-gated endpoint over the internal Docker network — the same host Caddy proxies to. */
|
||||||
|
export async function callCronEndpoint(slug: string, endpoint: string, timeoutMs: number): Promise<TaskResult> {
|
||||||
|
const env = await readEnvFile(path.join(stackDir(slug), ".env"));
|
||||||
|
const secret = env ? readEnvValue(env, "CRON_SECRET") : null;
|
||||||
|
if (!secret) return { ok: false, message: "no CRON_SECRET in this business's .env" };
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://next-chair-${slug}:3000/api/cron/${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${secret}` },
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
return { ok: response.ok, message: `HTTP ${response.status}` };
|
||||||
|
} catch {
|
||||||
|
// Not the error text: it can contain the URL, which is fine, but "unreachable" is all an operator needs and is stable for de-duplication.
|
||||||
|
return { ok: false, message: "unreachable (stopped, restarting or timed out)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationsTask: SchedulerTask = {
|
||||||
|
id: "notifications",
|
||||||
|
label: "Process notifications",
|
||||||
|
scope: "business",
|
||||||
|
intervalMs: (config) => config.notificationsIntervalSeconds * 1000,
|
||||||
|
run: ({ slug }) => callCronEndpoint(slug!, "process-notifications", NOTIFICATIONS_TIMEOUT_MS),
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewsTask: SchedulerTask = {
|
||||||
|
id: "reviews",
|
||||||
|
label: "Sync Google reviews (daily)",
|
||||||
|
scope: "business",
|
||||||
|
intervalMs: () => 24 * 60 * 60 * 1000,
|
||||||
|
run: ({ slug }) => callCronEndpoint(slug!, "sync-google-reviews", REVIEWS_TIMEOUT_MS),
|
||||||
|
};
|
||||||
|
|
||||||
|
const healthTask: SchedulerTask = {
|
||||||
|
id: "health",
|
||||||
|
label: "Health check",
|
||||||
|
scope: "business",
|
||||||
|
intervalMs: (config) => config.healthIntervalMinutes * 60 * 1000,
|
||||||
|
run: async ({ slug }) => {
|
||||||
|
const outcome = await runHealthCheck(slug!);
|
||||||
|
if (outcome.skipped) return { ok: true, message: `skipped: ${outcome.skipped}` };
|
||||||
|
const status = outcome.record?.status ?? "ok";
|
||||||
|
return { ok: status === "ok", message: `${status}${outcome.alertSent ? ` (alert sent: ${outcome.alertSent})` : ""}` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupsTask: SchedulerTask = {
|
||||||
|
id: "backups",
|
||||||
|
label: "Scheduled backup",
|
||||||
|
scope: "business",
|
||||||
|
intervalMs: () => 60_000, // unused — isDue decides
|
||||||
|
isDue: async ({ slug, now }) => isBackupDue(await readBackupSettings(slug!), now),
|
||||||
|
run: async ({ slug, now }) => {
|
||||||
|
try {
|
||||||
|
// Record the attempt first: a failing backup retries at the next scheduled time, not on every tick.
|
||||||
|
const started = await startBackupJob(slug!, "regular");
|
||||||
|
await recordBackupAttempt(slug!, now);
|
||||||
|
const job = await started.done;
|
||||||
|
return job.status === "succeeded"
|
||||||
|
? { ok: true, message: `saved ${String(job.result?.fileName ?? "backup")}` }
|
||||||
|
: { ok: false, message: job.error ?? "backup failed" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof BusinessLockedError) return { ok: true, message: "busy with another job — will retry" };
|
||||||
|
await recordBackupAttempt(slug!, now);
|
||||||
|
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sweepOlderThan(dir: string, maxAgeMs: number, now: Date): Promise<number> {
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await readdir(dir);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let removed = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
try {
|
||||||
|
const info = await stat(full);
|
||||||
|
if (now.getTime() - info.mtimeMs > maxAgeMs) {
|
||||||
|
await rm(full, { recursive: true, force: true });
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// vanished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maintenanceTask: SchedulerTask = {
|
||||||
|
id: "maintenance",
|
||||||
|
label: "Housekeeping",
|
||||||
|
scope: "global",
|
||||||
|
intervalMs: () => 60 * 60 * 1000,
|
||||||
|
run: async ({ now }) => {
|
||||||
|
const jobs = await pruneOldJobs(undefined, now);
|
||||||
|
// Uploaded archives not restored within a day, and staging folders a dead backup left behind.
|
||||||
|
const uploads = await sweepOlderThan(uploadsDir(), 24 * 60 * 60 * 1000, now);
|
||||||
|
const staging = await sweepOlderThan(stagingRoot(), 24 * 60 * 60 * 1000, now);
|
||||||
|
return { ok: true, message: `removed ${jobs} old job record(s), ${uploads} stale upload(s), ${staging} stale staging folder(s)` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Optional dead-man's-switch: pings an external URL (healthchecks.io, Better Stack…) so someone notices when the whole VPS or the Manager is down. */
|
||||||
|
const heartbeatTask: SchedulerTask = {
|
||||||
|
id: "heartbeat",
|
||||||
|
label: "External heartbeat ping",
|
||||||
|
scope: "global",
|
||||||
|
intervalMs: () => 5 * 60 * 1000,
|
||||||
|
run: async () => {
|
||||||
|
const url = process.env.MANAGER_HEARTBEAT_URL;
|
||||||
|
if (!url) return { ok: true, message: "not configured" };
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(10_000) });
|
||||||
|
return { ok: response.ok, message: `HTTP ${response.status}` };
|
||||||
|
} catch {
|
||||||
|
return { ok: false, message: "ping failed" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildSchedulerTasks(): SchedulerTask[] {
|
||||||
|
const tasks = [notificationsTask, reviewsTask, healthTask, backupsTask, maintenanceTask];
|
||||||
|
if (process.env.MANAGER_HEARTBEAT_URL) tasks.push(heartbeatTask);
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { globalSingleton } from "./singleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheduler engine: a registry of tasks, each with its own interval,
|
||||||
|
* driven by ONE periodic tick (see scheduler-start.ts). Everything
|
||||||
|
* environment-specific (which businesses exist, the clock, where state is
|
||||||
|
* kept, the config) is injected, so due-time and overlap behaviour are
|
||||||
|
* plain unit tests.
|
||||||
|
*
|
||||||
|
* Guards:
|
||||||
|
* - per-(task, business) overlap guard: a run still in flight is never
|
||||||
|
* started again, however slow the business is;
|
||||||
|
* - "last started" is persisted (scheduler-state.json), so a Manager
|
||||||
|
* restart doesn't make every once-a-day task fire again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TaskResult {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskRunContext {
|
||||||
|
/** null for global tasks. */
|
||||||
|
slug: string | null;
|
||||||
|
now: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerConfig {
|
||||||
|
notificationsIntervalSeconds: number;
|
||||||
|
healthIntervalMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerTask {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
scope: "global" | "business";
|
||||||
|
intervalMs(config: SchedulerConfig): number;
|
||||||
|
/** Replaces the interval check (e.g. backups are due by wall-clock schedule, not by elapsed time). */
|
||||||
|
isDue?(ctx: TaskRunContext & { lastStartedAt: Date | null }): Promise<boolean>;
|
||||||
|
run(ctx: TaskRunContext): Promise<TaskResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskState {
|
||||||
|
lastStartedAt: string | null;
|
||||||
|
lastFinishedAt: string | null;
|
||||||
|
lastOk: boolean | null;
|
||||||
|
lastMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerState {
|
||||||
|
version: 1;
|
||||||
|
heartbeatAt: string | null;
|
||||||
|
tasks: Record<string, TaskState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerStateStore {
|
||||||
|
get(): SchedulerState;
|
||||||
|
patch(key: string, patch: Partial<TaskState>): void;
|
||||||
|
setHeartbeat(at: Date): void;
|
||||||
|
/** Drops entries for tasks/businesses that no longer exist. */
|
||||||
|
retain(predicate: (key: string) => boolean): void;
|
||||||
|
flush(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taskKey(taskId: string, slug: string | null): string {
|
||||||
|
return slug ? `${taskId}:${slug}` : taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyState(): SchedulerState {
|
||||||
|
return { version: 1, heartbeatAt: null, tasks: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory store — the engine's default in tests; scheduler-start.ts uses a file-backed one. */
|
||||||
|
export class MemoryStateStore implements SchedulerStateStore {
|
||||||
|
private state: SchedulerState;
|
||||||
|
constructor(initial: SchedulerState = emptyState()) {
|
||||||
|
this.state = initial;
|
||||||
|
}
|
||||||
|
get(): SchedulerState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
patch(key: string, patch: Partial<TaskState>): void {
|
||||||
|
const current = this.state.tasks[key] ?? { lastStartedAt: null, lastFinishedAt: null, lastOk: null, lastMessage: null };
|
||||||
|
this.state.tasks[key] = { ...current, ...patch };
|
||||||
|
}
|
||||||
|
setHeartbeat(at: Date): void {
|
||||||
|
this.state.heartbeatAt = at.toISOString();
|
||||||
|
}
|
||||||
|
retain(predicate: (key: string) => boolean): void {
|
||||||
|
for (const key of Object.keys(this.state.tasks)) if (!predicate(key)) delete this.state.tasks[key];
|
||||||
|
}
|
||||||
|
async flush(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TickDeps {
|
||||||
|
tasks: SchedulerTask[];
|
||||||
|
store: SchedulerStateStore;
|
||||||
|
listSlugs(): Promise<string[]>;
|
||||||
|
config(): Promise<SchedulerConfig>;
|
||||||
|
now(): Date;
|
||||||
|
/** Keys of runs in flight. Defaults to a process-wide set so separate bundles share it. */
|
||||||
|
inFlight?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultInFlight(): Set<string> {
|
||||||
|
return globalSingleton("scheduler_in_flight", () => new Set<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides what's due and starts it. Resolves as soon as everything due has
|
||||||
|
* been STARTED; `done` settles when those runs finish. The production tick
|
||||||
|
* must not await `done` — a ten-minute backup may not stall the next tick's
|
||||||
|
* dispatch of notification crons. Tests await it.
|
||||||
|
*/
|
||||||
|
export async function runTick(deps: TickDeps): Promise<{ started: string[]; done: Promise<void> }> {
|
||||||
|
const inFlight = deps.inFlight ?? defaultInFlight();
|
||||||
|
const now = deps.now();
|
||||||
|
const config = await deps.config();
|
||||||
|
const slugs = await deps.listSlugs();
|
||||||
|
|
||||||
|
const started: string[] = [];
|
||||||
|
const runs: Promise<void>[] = [];
|
||||||
|
|
||||||
|
deps.store.setHeartbeat(now);
|
||||||
|
deps.store.retain((key) => {
|
||||||
|
const [taskId, slug] = key.split(":");
|
||||||
|
if (!deps.tasks.some((t) => t.id === taskId)) return false;
|
||||||
|
return !slug || slugs.includes(slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of deps.tasks) {
|
||||||
|
const targets: (string | null)[] = task.scope === "global" ? [null] : slugs;
|
||||||
|
for (const slug of targets) {
|
||||||
|
const key = taskKey(task.id, slug);
|
||||||
|
if (inFlight.has(key)) continue;
|
||||||
|
|
||||||
|
const lastRaw = deps.store.get().tasks[key]?.lastStartedAt ?? null;
|
||||||
|
const lastStartedAt = lastRaw ? new Date(lastRaw) : null;
|
||||||
|
const ctx: TaskRunContext = { slug, now };
|
||||||
|
|
||||||
|
let due: boolean;
|
||||||
|
try {
|
||||||
|
due = task.isDue
|
||||||
|
? await task.isDue({ ...ctx, lastStartedAt })
|
||||||
|
: lastStartedAt === null || now.getTime() - lastStartedAt.getTime() >= task.intervalMs(config);
|
||||||
|
} catch {
|
||||||
|
due = false;
|
||||||
|
}
|
||||||
|
if (!due) continue;
|
||||||
|
|
||||||
|
inFlight.add(key);
|
||||||
|
started.push(key);
|
||||||
|
deps.store.patch(key, { lastStartedAt: now.toISOString() });
|
||||||
|
runs.push(
|
||||||
|
(async () => {
|
||||||
|
let result: TaskResult;
|
||||||
|
try {
|
||||||
|
result = await task.run(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
result = { ok: false, message: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
deps.store.patch(key, {
|
||||||
|
lastFinishedAt: deps.now().toISOString(),
|
||||||
|
lastOk: result.ok,
|
||||||
|
lastMessage: result.message.slice(0, 300),
|
||||||
|
});
|
||||||
|
inFlight.delete(key);
|
||||||
|
await deps.store.flush().catch(() => undefined);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.store.flush().catch(() => undefined);
|
||||||
|
return { started, done: Promise.all(runs).then(() => undefined) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { runCapture, runQuiet, type CommandSpec } from "./process";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 7-Zip (`7zz`, from Alpine's `7zip` package — see Dockerfile) wrappers.
|
||||||
|
* Archives are 7z with AES-256 and encrypted headers (-mhe=on), so even
|
||||||
|
* the file names inside are hidden without the passphrase.
|
||||||
|
*
|
||||||
|
* The passphrase is passed as `-p<passphrase>` (7zz has no safer
|
||||||
|
* non-interactive channel). It is generated hex/base64url by the Manager
|
||||||
|
* or typed by the operator, and — like every command here — runs as an
|
||||||
|
* argument array, never through a shell.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sevenZipBinary(): string {
|
||||||
|
return process.env.SEVENZIP_BIN ?? "7zz";
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPassphrase(passphrase: string): void {
|
||||||
|
// An empty `-p` would make 7zz prompt on a terminal that doesn't exist.
|
||||||
|
if (passphrase.length < 8) throw new Error("The backup passphrase must be at least 8 characters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sevenZipCreateCommand(params: { archivePath: string; cwd: string; entries: string[]; passphrase: string }): CommandSpec {
|
||||||
|
assertPassphrase(params.passphrase);
|
||||||
|
return {
|
||||||
|
command: sevenZipBinary(),
|
||||||
|
args: ["a", "-t7z", "-mx=3", "-mhe=on", `-p${params.passphrase}`, "-y", "-bd", params.archivePath, ...params.entries],
|
||||||
|
cwd: params.cwd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sevenZipTestCommand(archivePath: string, passphrase: string): CommandSpec {
|
||||||
|
assertPassphrase(passphrase);
|
||||||
|
return { command: sevenZipBinary(), args: ["t", "-t7z", `-p${passphrase}`, "-y", "-bd", archivePath] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Streams one archive member to stdout (`-so`) — for piping a dump/tar straight into docker without unpacking to disk. */
|
||||||
|
export function sevenZipExtractToStdoutCommand(archivePath: string, passphrase: string, member: string): CommandSpec {
|
||||||
|
assertPassphrase(passphrase);
|
||||||
|
return { command: sevenZipBinary(), args: ["x", "-so", "-t7z", `-p${passphrase}`, "-y", "-bd", "-bso0", "-bsp0", archivePath, member] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sevenZipCreate(params: Parameters<typeof sevenZipCreateCommand>[0]): Promise<void> {
|
||||||
|
await runQuiet(sevenZipCreateCommand(params));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throws if the archive is corrupt or the passphrase is wrong. */
|
||||||
|
export async function sevenZipTest(archivePath: string, passphrase: string): Promise<void> {
|
||||||
|
await runQuiet(sevenZipTestCommand(archivePath, passphrase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For SMALL members only (manifest.json, .env) — capped at 1 MiB. */
|
||||||
|
export async function sevenZipReadMember(archivePath: string, passphrase: string, member: string): Promise<string> {
|
||||||
|
const { stdout } = await runCapture(sevenZipExtractToStdoutCommand(archivePath, passphrase, member), { maxBytes: 1024 * 1024 });
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Process-wide state that must be shared between Next's separately
|
||||||
|
* bundled entry points (instrumentation.ts, route handlers, server
|
||||||
|
* actions) and survive dev-server module reloads. A plain module-level
|
||||||
|
* variable is NOT enough: each of those entry points can get its own copy
|
||||||
|
* of a module, so in-memory guards (the scheduler's interval, per-task
|
||||||
|
* overlap guards, the job-lock mutex) live on `globalThis` instead.
|
||||||
|
*/
|
||||||
|
export function globalSingleton<T>(key: string, init: () => T): T {
|
||||||
|
const store = globalThis as unknown as Record<string, unknown>;
|
||||||
|
const fullKey = `__nextchair_manager_${key}`;
|
||||||
|
if (!(fullKey in store)) store[fullKey] = init();
|
||||||
|
return store[fullKey] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises async work per key within this process — used around every
|
||||||
|
* read-modify-write of a shared JSON file, so a scheduler tick and a
|
||||||
|
* server action can't interleave and lose each other's update.
|
||||||
|
*/
|
||||||
|
export function withMutex<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const chains = globalSingleton("mutex_chains", () => new Map<string, Promise<unknown>>());
|
||||||
|
const previous = chains.get(key) ?? Promise.resolve();
|
||||||
|
const run = previous.then(fn, fn);
|
||||||
|
// Keep the chain alive but never let one failure poison the next caller.
|
||||||
|
chains.set(
|
||||||
|
key,
|
||||||
|
run.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* bundle.
|
* bundle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const RESERVED_SLUGS = new Set(["infra", "manager"]);
|
export const RESERVED_SLUGS = new Set(["infra", "manager", "backups", "restore"]);
|
||||||
|
|
||||||
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
|
const SLUG_PATTERN = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/;
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ export async function listBusinessStacks(): Promise<BusinessStackSummary[]> {
|
|||||||
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
|
return stacks.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Slugs of every provisioned stack — directory scan only (no `docker compose ps` per stack, unlike listBusinessStacks), cheap enough for the scheduler to call every few seconds. */
|
||||||
|
export async function listStackSlugs(): Promise<string[]> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await readdir(stacksRoot());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const slugs: string[] = [];
|
||||||
|
for (const slug of entries) {
|
||||||
|
if (RESERVED_SLUGS.has(slug) || slug.startsWith(".")) continue;
|
||||||
|
try {
|
||||||
|
await access(path.join(stacksRoot(), slug, "compose.yaml"));
|
||||||
|
slugs.push(slug);
|
||||||
|
} catch {
|
||||||
|
// not a stack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slugs.sort();
|
||||||
|
}
|
||||||
|
|
||||||
export async function stackExists(slug: string): Promise<boolean> {
|
export async function stackExists(slug: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await access(path.join(stackDir(slug), "compose.yaml"));
|
await access(path.join(stackDir(slug), "compose.yaml"));
|
||||||
|
|||||||
@@ -64,6 +64,44 @@ export async function sendTestSmtpEmail(config: TestEmailConfig): Promise<TestEm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlainEmailConfig extends Omit<TestEmailConfig, "recipient"> {
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same transport + error-categorising contract as sendTestSmtpEmail, for
|
||||||
|
* the operator alert emails (health / backup failures). Plain text only;
|
||||||
|
* never throws and never echoes raw SMTP errors.
|
||||||
|
*/
|
||||||
|
export async function sendPlainSmtpEmail(config: PlainEmailConfig): Promise<TestEmailResult> {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.encryption === "tls",
|
||||||
|
ignoreTLS: config.encryption === "none",
|
||||||
|
auth: { user: config.username, pass: config.password },
|
||||||
|
connectionTimeout: 10_000,
|
||||||
|
greetingTimeout: 10_000,
|
||||||
|
socketTimeout: 20_000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: config.fromName ? `"${config.fromName}" <${config.fromAddress}>` : config.fromAddress,
|
||||||
|
to: config.recipient,
|
||||||
|
replyTo: config.replyTo || undefined,
|
||||||
|
subject: config.subject,
|
||||||
|
text: config.text,
|
||||||
|
});
|
||||||
|
return { ok: true, message: `Sent to ${config.recipient}.` };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: categorizeSmtpError(error) };
|
||||||
|
} finally {
|
||||||
|
transporter.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function categorizeSmtpError(error: unknown): string {
|
function categorizeSmtpError(error: unknown): string {
|
||||||
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : "";
|
const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : "";
|
||||||
const responseCode =
|
const responseCode =
|
||||||
|
|||||||
@@ -26,5 +26,9 @@ export const config = {
|
|||||||
// Runs on every page and API route except static assets — this app has
|
// Runs on every page and API route except static assets — this app has
|
||||||
// no public-facing content at all, unlike the main app's proxy.ts,
|
// no public-facing content at all, unlike the main app's proxy.ts,
|
||||||
// which has to carve out installation/customer-facing exceptions.
|
// which has to carve out installation/customer-facing exceptions.
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
//
|
||||||
|
// api/backups/upload is excluded deliberately: a proxy buffers (and
|
||||||
|
// truncates at 10 MB) every request body it sees, which would corrupt a
|
||||||
|
// large backup upload. That route verifies the session cookie itself.
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/backups/upload).*)"],
|
||||||
};
|
};
|
||||||
@@ -4,7 +4,6 @@ import Link from "next/link";
|
|||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
@@ -29,12 +28,26 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
|
|||||||
NO_SHOW: "no_show",
|
NO_SHOW: "no_show",
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPCOMING_STATUSES = ["PENDING", "CONFIRMED", "CHECKED_IN", "IN_PROGRESS"] as const;
|
|
||||||
const CANCELLED_STATUSES = ["CANCELLED", "NO_SHOW"] as const;
|
|
||||||
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
const RESCHEDULABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
||||||
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
const CANCELLABLE_STATUSES = new Set(["PENDING", "CONFIRMED", "CHECKED_IN"]);
|
||||||
|
const REBOOKABLE_STATUSES = new Set(["COMPLETED", "CANCELLED", "NO_SHOW"]);
|
||||||
|
|
||||||
export default async function AppointmentsPage() {
|
const PAGE_SIZE = 20;
|
||||||
|
const MAX_LIMIT = 500;
|
||||||
|
|
||||||
|
function parseLimit(raw: string | undefined): number {
|
||||||
|
const n = Number.parseInt(raw ?? "", 10);
|
||||||
|
if (!Number.isFinite(n) || n < PAGE_SIZE) return PAGE_SIZE;
|
||||||
|
return Math.min(n, MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AppointmentsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ limit?: string }>;
|
||||||
|
}) {
|
||||||
|
const { limit: rawLimit } = await searchParams;
|
||||||
|
const limit = parseLimit(rawLimit);
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
@@ -64,7 +77,9 @@ export default async function AppointmentsPage() {
|
|||||||
const customerProfile = await prisma.customerProfile.findUnique({
|
const customerProfile = await prisma.customerProfile.findUnique({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
});
|
});
|
||||||
const bell = <InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />;
|
const bell = (
|
||||||
|
<InlineNotificationBell href="/notifications" unreadCount={unreadNotificationCount} />
|
||||||
|
);
|
||||||
|
|
||||||
if (!customerProfile) {
|
if (!customerProfile) {
|
||||||
return (
|
return (
|
||||||
@@ -86,37 +101,28 @@ export default async function AppointmentsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [upcoming, past, cancelled] = await Promise.all([
|
// One list, newest first. Fetch a spare row so we know whether "Show older" is needed.
|
||||||
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...UPCOMING_STATUSES] }),
|
const fetched = await listAppointments(prisma, {
|
||||||
listAppointments(prisma, { customerId: customerProfile.id, statuses: ["COMPLETED"] }),
|
customerId: customerProfile.id,
|
||||||
listAppointments(prisma, { customerId: customerProfile.id, statuses: [...CANCELLED_STATUSES] }),
|
order: "desc",
|
||||||
]);
|
take: limit + 1,
|
||||||
|
});
|
||||||
|
const hasMore = fetched.length > limit;
|
||||||
|
const appointments = hasMore ? fetched.slice(0, limit) : fetched;
|
||||||
|
|
||||||
const renderList = (appointments: typeof upcoming, kind: "upcoming" | "past" | "cancelled") => {
|
const list =
|
||||||
if (appointments.length === 0) {
|
appointments.length === 0 ? (
|
||||||
return (
|
<EmptyState
|
||||||
<EmptyState
|
icon={CalendarPlus}
|
||||||
icon={CalendarPlus}
|
title="No appointments yet"
|
||||||
title={
|
description="Book your next visit in a few taps."
|
||||||
kind === "upcoming"
|
action={
|
||||||
? "No upcoming appointments"
|
<Button asChild>
|
||||||
: kind === "past"
|
<Link href="/book">Book Now</Link>
|
||||||
? "No past appointments"
|
</Button>
|
||||||
: "No cancelled appointments"
|
}
|
||||||
}
|
/>
|
||||||
description={kind === "upcoming" ? "Book your next visit in a few taps." : undefined}
|
) : (
|
||||||
action={
|
|
||||||
kind === "upcoming" ? (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/book">Book Now</Link>
|
|
||||||
</Button>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{appointments.map((appointment) => (
|
{appointments.map((appointment) => (
|
||||||
<AppointmentCard
|
<AppointmentCard
|
||||||
@@ -128,6 +134,7 @@ export default async function AppointmentsPage() {
|
|||||||
weekday: "short",
|
weekday: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
timeZone: business.timezone,
|
timeZone: business.timezone,
|
||||||
})}
|
})}
|
||||||
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
|
formattedTime={appointment.startAt.toLocaleTimeString("en-GB", {
|
||||||
@@ -143,7 +150,7 @@ export default async function AppointmentsPage() {
|
|||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
{kind === "upcoming" && RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/appointments/${appointment.id}/reschedule`}
|
href={`/appointments/${appointment.id}/reschedule`}
|
||||||
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||||
@@ -151,33 +158,34 @@ export default async function AppointmentsPage() {
|
|||||||
Reschedule
|
Reschedule
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{kind === "upcoming" && CANCELLABLE_STATUSES.has(appointment.status) && (
|
{CANCELLABLE_STATUSES.has(appointment.status) && (
|
||||||
<CancelAppointmentDialog appointmentId={appointment.id} />
|
<CancelAppointmentDialog appointmentId={appointment.id} />
|
||||||
)}
|
)}
|
||||||
{kind !== "upcoming" && <BookAgainButton appointmentId={appointment.id} />}
|
{REBOOKABLE_STATUSES.has(appointment.status) && (
|
||||||
|
<BookAgainButton appointmentId={appointment.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<Link
|
||||||
|
href={`/appointments?limit=${limit + PAGE_SIZE}`}
|
||||||
|
scroll={false}
|
||||||
|
className="border-line rounded-lg border px-4 py-2 text-xs font-bold tracking-widest uppercase"
|
||||||
|
>
|
||||||
|
Show older
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
<TopNav title="Appointments" sticky className="bg-stone" action={bell} />
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-6">
|
<div className="mx-auto max-w-2xl px-6 pt-6">{list}</div>
|
||||||
<Tabs defaultValue="upcoming">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="upcoming">Upcoming</TabsTrigger>
|
|
||||||
<TabsTrigger value="past">Past</TabsTrigger>
|
|
||||||
<TabsTrigger value="cancelled">Cancelled</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="upcoming">{renderList(upcoming, "upcoming")}</TabsContent>
|
|
||||||
<TabsContent value="past">{renderList(past, "past")}</TabsContent>
|
|
||||||
<TabsContent value="cancelled">{renderList(cancelled, "cancelled")}</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -35,14 +35,14 @@ export default async function OwnerStaffTimeOffPage({ params }: { params: Promis
|
|||||||
if (!target) notFound();
|
if (!target) notFound();
|
||||||
|
|
||||||
const { upcoming, past } = await listTimeOff(prisma, id);
|
const { upcoming, past } = await listTimeOff(prisma, id);
|
||||||
// Whole-day rows only — partial-day "Block Time" entries are managed from the owner calendar (see owner-block-time-button.tsx).
|
|
||||||
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
|
|
||||||
const toEditable = (rows: typeof upcoming) =>
|
const toEditable = (rows: typeof upcoming) =>
|
||||||
wholeDay(rows).map((t) => ({
|
rows.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
startDate: toCalendarDate(t.startDate),
|
startDate: toCalendarDate(t.startDate),
|
||||||
endDate: toCalendarDate(t.endDate),
|
endDate: toCalendarDate(t.endDate),
|
||||||
reason: t.reason,
|
reason: t.reason,
|
||||||
|
startMinutes: t.startMinutes,
|
||||||
|
endMinutes: t.endMinutes,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CalendarX2, Mail, Phone } from "lucide-react";
|
import { CalendarX2, Download, Mail, Phone } from "lucide-react";
|
||||||
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
import { WhatsAppButton } from "@/features/staff/components/whatsapp-button";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||||
@@ -12,6 +12,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
import { clientDirectoryWhere } from "@/services/customer/client-directory";
|
||||||
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
@@ -60,6 +62,9 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
(await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0;
|
(await prisma.customerProfile.count({ where: { AND: [{ id }, clientDirectoryWhere(context.businessId)] } })) > 0;
|
||||||
if (!inDirectory) notFound();
|
if (!inDirectory) notFound();
|
||||||
|
|
||||||
|
// Only offered to those who may use it; the export route re-checks the same permission itself.
|
||||||
|
const canExport = await hasPermission(context.userId, Permission.MANAGE_BUSINESS_PROFILE);
|
||||||
|
|
||||||
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
const [upcoming, past, cancelled, notesRaw, loyalty] = await Promise.all([
|
||||||
listAppointments(prisma, {
|
listAppointments(prisma, {
|
||||||
customerId: id,
|
customerId: id,
|
||||||
@@ -271,6 +276,22 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canExport && (
|
||||||
|
<div className="border-line border-t pt-4">
|
||||||
|
{/* A plain link, not next/link: the response is a file download, not a page. */}
|
||||||
|
<a
|
||||||
|
href={`/api/customers/${customer.id}/export`}
|
||||||
|
download
|
||||||
|
className="border-line flex h-12 w-full items-center justify-center gap-2 rounded-md border bg-white px-4 text-xs font-bold"
|
||||||
|
>
|
||||||
|
<Download className="text-accent h-3.5 w-3.5" aria-hidden="true" /> Export client data (CSV)
|
||||||
|
</a>
|
||||||
|
<p className="text-muted mt-2 text-xs">
|
||||||
|
Profile, appointments, loyalty and messages sent. Private staff notes are not included.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4 lg:mt-0">
|
<div className="mt-4 space-y-4 lg:mt-0">
|
||||||
|
|||||||
@@ -18,15 +18,14 @@ export default async function StaffTimeOffPage() {
|
|||||||
if (!staff) redirect("/home");
|
if (!staff) redirect("/home");
|
||||||
|
|
||||||
const { upcoming, past } = await listTimeOff(prisma, staff.id);
|
const { upcoming, past } = await listTimeOff(prisma, staff.id);
|
||||||
// Whole-day rows only — partial-day "Block Time" entries are managed from the calendar (see block-time-button.tsx).
|
|
||||||
const wholeDay = (rows: typeof upcoming) => rows.filter((r) => r.startMinutes === null && r.endMinutes === null);
|
|
||||||
|
|
||||||
const toEditable = (rows: typeof upcoming) =>
|
const toEditable = (rows: typeof upcoming) =>
|
||||||
wholeDay(rows).map((t) => ({
|
rows.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
startDate: toCalendarDate(t.startDate),
|
startDate: toCalendarDate(t.startDate),
|
||||||
endDate: toCalendarDate(t.endDate),
|
endDate: toCalendarDate(t.endDate),
|
||||||
reason: t.reason,
|
reason: t.reason,
|
||||||
|
startMinutes: t.startMinutes,
|
||||||
|
endMinutes: t.endMinutes,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
|
import { prisma } from "@/lib/db/prisma";
|
||||||
|
import { exportCustomerData } from "@/services/customer/customer-export.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download one customer's data as CSV. A Route Handler (not a Server Action)
|
||||||
|
* because the browser needs real Content-Type / Content-Disposition headers,
|
||||||
|
* same reason as the appointment .ics download. All authorisation —
|
||||||
|
* MANAGE_BUSINESS_PROFILE, business scoping, the audit entry — lives in
|
||||||
|
* exportCustomerData; the client detail page that links here is open to every
|
||||||
|
* staff member, so nothing may rely on having reached this URL from it.
|
||||||
|
*/
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "You must be signed in." }, { status: 401 });
|
||||||
|
|
||||||
|
const context = await getActingBusinessContext();
|
||||||
|
if (!context) return NextResponse.json({ error: "You don't have permission to do that." }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: session.user.id,
|
||||||
|
businessId: context.businessId,
|
||||||
|
customerId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return result.reason === "NOT_FOUND"
|
||||||
|
? NextResponse.json({ error: "Customer not found." }, { status: 404 })
|
||||||
|
: NextResponse.json({ error: "You don't have permission to do that." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(result.csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${result.filename}"`,
|
||||||
|
// Personal data: never cache, in the browser or any proxy.
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -53,12 +53,14 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise<C
|
|||||||
|
|
||||||
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
await syncAppointmentSafely(prisma, parsed.data.appointmentId);
|
||||||
|
|
||||||
// Deliberately not revalidating here (unlike reschedule) — every affected
|
// Deliberately not revalidating here (unlike reschedule). Every affected
|
||||||
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
|
// page is already `dynamic = "force-dynamic"`, so revalidatePath buys no
|
||||||
// extra freshness, and doing it immediately would drop the cancelled
|
// extra freshness, and doing it immediately would re-render the card as
|
||||||
// appointment out of the Upcoming list mid-render, unmounting this very
|
// Cancelled — its actions are derived from status, so the Cancel button
|
||||||
// dialog before the customer can read the late-cancellation notice or
|
// (and the dialog it owns) is swapped for Book Again and unmounted before
|
||||||
// click "Done". The dialog's own onClose already calls router.refresh().
|
// the customer can read the late-cancellation notice or click "Done". The
|
||||||
|
// dialog's own onClose already calls router.refresh(), and the card then
|
||||||
|
// stays in the list with a Cancelled pill.
|
||||||
return { success: true, wasLateCancellation: result.wasLateCancellation };
|
return { success: true, wasLateCancellation: result.wasLateCancellation };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { Weekday } from "@/generated/prisma/enums";
|
import { Weekday } from "@/generated/prisma/enums";
|
||||||
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff } from "@/services/staff/staff.service";
|
import { createBreak, createTimeOff, deleteBreak, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||||
import {
|
import {
|
||||||
cleanupTestBusiness,
|
cleanupTestBusiness,
|
||||||
|
createTestAppointment,
|
||||||
createTestBusiness,
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
createTestStaff,
|
createTestStaff,
|
||||||
prisma,
|
prisma,
|
||||||
} from "@/services/scheduling/__tests__/fixtures";
|
} from "@/services/scheduling/__tests__/fixtures";
|
||||||
@@ -91,4 +93,93 @@ describe("Owner-managed breaks and time off (integration)", () => {
|
|||||||
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
|
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
|
||||||
expect(staffBBreaks).toHaveLength(0);
|
expect(staffBBreaks).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("partial-day Block Time entries (edit and delete)", () => {
|
||||||
|
async function createBlock(staffId: string) {
|
||||||
|
const created = await createTimeOff(prisma, {
|
||||||
|
staffId,
|
||||||
|
businessTimezone: business.timezone,
|
||||||
|
input: { startDate: "2027-02-01", endDate: "2027-02-01", reason: "Team meeting", startMinutes: 14 * 60, endMinutes: 15 * 60 },
|
||||||
|
});
|
||||||
|
expect(created.success).toBe(true);
|
||||||
|
if (!created.success) throw new Error("setup failed");
|
||||||
|
return created.timeOff;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("edits the date, times and reason of a block, keeping it partial-day", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const block = await createBlock(staffId);
|
||||||
|
|
||||||
|
const updated = await updateTimeOff(prisma, {
|
||||||
|
timeOffId: block.id,
|
||||||
|
staffId,
|
||||||
|
businessTimezone: business.timezone,
|
||||||
|
input: { startDate: "2027-02-02", endDate: "2027-02-02", reason: "Dentist", startMinutes: 16 * 60, endMinutes: 17 * 60 + 30 },
|
||||||
|
});
|
||||||
|
expect(updated.success).toBe(true);
|
||||||
|
|
||||||
|
const stored = await prisma.timeOff.findUnique({ where: { id: block.id } });
|
||||||
|
expect(stored?.startDate.toISOString().slice(0, 10)).toBe("2027-02-02");
|
||||||
|
expect(stored?.startMinutes).toBe(16 * 60);
|
||||||
|
expect(stored?.endMinutes).toBe(17 * 60 + 30);
|
||||||
|
expect(stored?.reason).toBe("Dentist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid edited range and non-integer minutes without touching the row", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const block = await createBlock(staffId);
|
||||||
|
|
||||||
|
for (const bad of [
|
||||||
|
{ startMinutes: 15 * 60, endMinutes: 14 * 60 },
|
||||||
|
{ startMinutes: 14 * 60 + 0.5, endMinutes: 15 * 60 },
|
||||||
|
]) {
|
||||||
|
const result = await updateTimeOff(prisma, {
|
||||||
|
timeOffId: block.id,
|
||||||
|
staffId,
|
||||||
|
businessTimezone: business.timezone,
|
||||||
|
input: { startDate: "2027-02-01", endDate: "2027-02-01", ...bad },
|
||||||
|
});
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
}
|
||||||
|
const stored = await prisma.timeOff.findUnique({ where: { id: block.id } });
|
||||||
|
expect(stored?.startMinutes).toBe(14 * 60);
|
||||||
|
expect(stored?.endMinutes).toBe(15 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a conflicting appointment when an edited block overlaps it, and saves once acknowledged", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
// Mon 1 Feb 2027, 10:00–10:30 London (GMT).
|
||||||
|
await createTestAppointment(business, { customerId, staffId, startAt: new Date("2027-02-01T10:00:00Z") });
|
||||||
|
const block = await createBlock(staffId);
|
||||||
|
|
||||||
|
const input = { startDate: "2027-02-01", endDate: "2027-02-01", startMinutes: 10 * 60, endMinutes: 11 * 60 };
|
||||||
|
const blocked = await updateTimeOff(prisma, { timeOffId: block.id, staffId, businessTimezone: business.timezone, input });
|
||||||
|
expect(blocked.success).toBe(false);
|
||||||
|
expect(blocked.conflicts).toHaveLength(1);
|
||||||
|
expect((await prisma.timeOff.findUnique({ where: { id: block.id } }))?.startMinutes).toBe(14 * 60);
|
||||||
|
|
||||||
|
const forced = await updateTimeOff(prisma, {
|
||||||
|
timeOffId: block.id,
|
||||||
|
staffId,
|
||||||
|
businessTimezone: business.timezone,
|
||||||
|
input,
|
||||||
|
acknowledgeConflicts: true,
|
||||||
|
});
|
||||||
|
expect(forced.success).toBe(true);
|
||||||
|
expect((await prisma.timeOff.findUnique({ where: { id: block.id } }))?.startMinutes).toBe(10 * 60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a block, but never another staff member's block", async () => {
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const { staffId: otherStaffId } = await createTestStaff(business);
|
||||||
|
const block = await createBlock(staffId);
|
||||||
|
|
||||||
|
expect((await deleteTimeOff(prisma, { timeOffId: block.id, staffId: otherStaffId })).success).toBe(false);
|
||||||
|
expect(await prisma.timeOff.findUnique({ where: { id: block.id } })).not.toBeNull();
|
||||||
|
|
||||||
|
expect((await deleteTimeOff(prisma, { timeOffId: block.id, staffId })).success).toBe(true);
|
||||||
|
expect(await prisma.timeOff.findUnique({ where: { id: block.id } })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -9,6 +9,9 @@ import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/st
|
|||||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||||
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
import { notifyStaffScheduleChanged } from "@/services/staff/staff-notifications";
|
||||||
|
|
||||||
|
/** Whole-day time off omits the minutes; an edited partial-day "Block Time" entry carries both. */
|
||||||
|
type TimeOffActionInput = Pick<TimeOffInput, "startDate" | "endDate" | "reason" | "startMinutes" | "endMinutes">;
|
||||||
|
|
||||||
export interface TimeOffConflictItem {
|
export interface TimeOffConflictItem {
|
||||||
id: string;
|
id: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
@@ -57,7 +60,7 @@ async function requireStaffManagerFor(staffId: string) {
|
|||||||
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
|
/** Owner-on-behalf-of-staff time off — mirrors breaks.ts exactly, including why the per-staffId binding lives in the separate time-off-action-set.ts factory rather than here. */
|
||||||
export async function ownerCreateTimeOffAction(
|
export async function ownerCreateTimeOffAction(
|
||||||
staffId: string,
|
staffId: string,
|
||||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
input: TimeOffActionInput,
|
||||||
acknowledgeConflicts?: boolean,
|
acknowledgeConflicts?: boolean,
|
||||||
): Promise<TimeOffActionResult> {
|
): Promise<TimeOffActionResult> {
|
||||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||||
@@ -91,7 +94,7 @@ export async function ownerCreateTimeOffAction(
|
|||||||
export async function ownerUpdateTimeOffAction(
|
export async function ownerUpdateTimeOffAction(
|
||||||
staffId: string,
|
staffId: string,
|
||||||
timeOffId: string,
|
timeOffId: string,
|
||||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
input: TimeOffActionInput,
|
||||||
acknowledgeConflicts?: boolean,
|
acknowledgeConflicts?: boolean,
|
||||||
): Promise<TimeOffActionResult> {
|
): Promise<TimeOffActionResult> {
|
||||||
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
const { owner, target, error } = await requireStaffManagerFor(staffId);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
|||||||
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/staff.service";
|
||||||
import type { TimeOffInput } from "@/services/staff/staff.service";
|
import type { TimeOffInput } from "@/services/staff/staff.service";
|
||||||
|
|
||||||
|
/** Whole-day time off omits the minutes; an edited partial-day "Block Time" entry carries both. */
|
||||||
|
type TimeOffActionInput = Pick<TimeOffInput, "startDate" | "endDate" | "reason" | "startMinutes" | "endMinutes">;
|
||||||
|
|
||||||
export interface TimeOffConflictItem {
|
export interface TimeOffConflictItem {
|
||||||
id: string;
|
id: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
@@ -27,9 +30,9 @@ function mapConflicts(conflicts: { id: string; customer: { firstName: string; la
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whole-day time off only — the settings editor doesn't handle partial-day blocks; that's the calendar's "Block Time" action (see block-time.ts). */
|
/** Creates whole-day time off. Partial-day "Block Time" entries are created from the calendar (see block-time.ts) but listed, edited and removed with the same update/delete actions below. */
|
||||||
export async function createTimeOffAction(
|
export async function createTimeOffAction(
|
||||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
input: TimeOffActionInput,
|
||||||
acknowledgeConflicts?: boolean,
|
acknowledgeConflicts?: boolean,
|
||||||
): Promise<TimeOffActionResult> {
|
): Promise<TimeOffActionResult> {
|
||||||
const staff = await getActingStaff();
|
const staff = await getActingStaff();
|
||||||
@@ -49,7 +52,7 @@ export async function createTimeOffAction(
|
|||||||
|
|
||||||
export async function updateTimeOffAction(
|
export async function updateTimeOffAction(
|
||||||
timeOffId: string,
|
timeOffId: string,
|
||||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
input: TimeOffActionInput,
|
||||||
acknowledgeConflicts?: boolean,
|
acknowledgeConflicts?: boolean,
|
||||||
): Promise<TimeOffActionResult> {
|
): Promise<TimeOffActionResult> {
|
||||||
const staff = await getActingStaff();
|
const staff = await getActingStaff();
|
||||||
|
|||||||
@@ -15,17 +15,38 @@ export interface EditableTimeOff {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
/** Both set = a partial-day "Block Time" entry (single day, minutes from midnight); both null/omitted = whole-day time off. */
|
||||||
|
startMinutes?: number | null;
|
||||||
|
endMinutes?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeOffSheetInput = Pick<TimeOffInput, "startDate" | "endDate" | "reason" | "startMinutes" | "endMinutes">;
|
||||||
|
|
||||||
|
export function isPartialDay(t: Pick<EditableTimeOff, "startMinutes" | "endMinutes">): boolean {
|
||||||
|
return t.startMinutes != null && t.endMinutes != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minutesToHhmm(minutes: number): string {
|
||||||
|
minutes = Math.min(minutes, 24 * 60 - 1); // a block ending at midnight (1440) shows as 23:59 in a time input
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hhmmToMinutes(hhmm: string): number | null {
|
||||||
|
const match = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||||
|
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
|
/** Same DI shape as AppointmentStatusActions' `actions` prop — the owner-on-behalf-of-staff editor injects its own Server Actions bound to a chosen staffId instead of forking this component. */
|
||||||
export interface TimeOffActionSet {
|
export interface TimeOffActionSet {
|
||||||
create: (
|
create: (
|
||||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
input: TimeOffSheetInput,
|
||||||
acknowledgeConflicts?: boolean,
|
acknowledgeConflicts?: boolean,
|
||||||
) => Promise<TimeOffActionResult>;
|
) => Promise<TimeOffActionResult>;
|
||||||
update: (
|
update: (
|
||||||
timeOffId: string,
|
timeOffId: string,
|
||||||
input: Pick<TimeOffInput, "startDate" | "endDate" | "reason">,
|
input: TimeOffSheetInput,
|
||||||
acknowledgeConflicts?: boolean,
|
acknowledgeConflicts?: boolean,
|
||||||
) => Promise<TimeOffActionResult>;
|
) => Promise<TimeOffActionResult>;
|
||||||
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
|
remove: (timeOffId: string) => Promise<TimeOffActionResult>;
|
||||||
@@ -58,6 +79,9 @@ export function TimeOffFormSheet({
|
|||||||
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
|
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
|
||||||
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
|
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
|
||||||
const [reason, setReason] = useState(existing?.reason ?? "");
|
const [reason, setReason] = useState(existing?.reason ?? "");
|
||||||
|
const partial = existing ? isPartialDay(existing) : false;
|
||||||
|
const [startTime, setStartTime] = useState(partial ? minutesToHhmm(existing!.startMinutes!) : "12:00");
|
||||||
|
const [endTime, setEndTime] = useState(partial ? minutesToHhmm(existing!.endMinutes!) : "13:00");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
|
const [conflicts, setConflicts] = useState<TimeOffConflictItem[] | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
@@ -67,6 +91,8 @@ export function TimeOffFormSheet({
|
|||||||
setStartDate(existing?.startDate ?? todayIso());
|
setStartDate(existing?.startDate ?? todayIso());
|
||||||
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
|
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
|
||||||
setReason(existing?.reason ?? "");
|
setReason(existing?.reason ?? "");
|
||||||
|
setStartTime(partial ? minutesToHhmm(existing!.startMinutes!) : "12:00");
|
||||||
|
setEndTime(partial ? minutesToHhmm(existing!.endMinutes!) : "13:00");
|
||||||
setError(null);
|
setError(null);
|
||||||
setConflicts(null);
|
setConflicts(null);
|
||||||
};
|
};
|
||||||
@@ -77,13 +103,27 @@ export function TimeOffFormSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const submit = (acknowledgeConflicts: boolean) => {
|
const submit = (acknowledgeConflicts: boolean) => {
|
||||||
if (startDate > endDate) {
|
let minutes: Pick<TimeOffSheetInput, "startMinutes" | "endMinutes"> = {};
|
||||||
|
if (partial) {
|
||||||
|
const startMinutes = hhmmToMinutes(startTime);
|
||||||
|
const endMinutes = hhmmToMinutes(endTime);
|
||||||
|
if (startMinutes === null || endMinutes === null || startMinutes >= endMinutes) {
|
||||||
|
setError("Start time must be before end time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
minutes = { startMinutes, endMinutes };
|
||||||
|
} else if (startDate > endDate) {
|
||||||
setError("Start date must be on or before the end date.");
|
setError("Start date must be on or before the end date.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const input = { startDate, endDate, reason: reason.trim() || undefined };
|
const input = {
|
||||||
|
startDate,
|
||||||
|
endDate: partial ? startDate : endDate,
|
||||||
|
reason: reason.trim() || undefined,
|
||||||
|
...minutes,
|
||||||
|
};
|
||||||
const result: TimeOffActionResult = existing
|
const result: TimeOffActionResult = existing
|
||||||
? await actions.update(existing.id, input, acknowledgeConflicts)
|
? await actions.update(existing.id, input, acknowledgeConflicts)
|
||||||
: await actions.create(input, acknowledgeConflicts);
|
: await actions.create(input, acknowledgeConflicts);
|
||||||
@@ -102,7 +142,7 @@ export function TimeOffFormSheet({
|
|||||||
startDeleteTransition(async () => {
|
startDeleteTransition(async () => {
|
||||||
const result = await actions.remove(existing.id);
|
const result = await actions.remove(existing.id);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error ?? "Couldn't remove this time off.");
|
setError(result.error ?? (partial ? "Couldn't remove this block." : "Couldn't remove this time off."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -111,7 +151,7 @@ export function TimeOffFormSheet({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheet open={open} onClose={handleClose} title={existing ? "Edit Time Off" : "New Time Off"}>
|
<BottomSheet open={open} onClose={handleClose} title={partial ? "Edit Blocked Time" : existing ? "Edit Time Off" : "New Time Off"}>
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
{conflicts && conflicts.length > 0 ? (
|
{conflicts && conflicts.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -146,13 +186,23 @@ export function TimeOffFormSheet({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{partial ? (
|
||||||
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
<>
|
||||||
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
<Input label="Date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input label="Start" type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
|
||||||
|
<Input label="End" type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input label="Start date" type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
<Input label="End date" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Reason (optional)"
|
label="Reason (optional)"
|
||||||
placeholder="e.g. Vacation"
|
placeholder={partial ? "e.g. Team meeting" : "e.g. Vacation"}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -166,7 +216,7 @@ export function TimeOffFormSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
{existing && (
|
{existing && (
|
||||||
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
|
<Button variant="ghost" className="w-full text-danger" isLoading={isDeleting} onClick={handleDelete}>
|
||||||
Remove Time Off
|
{partial ? "Remove Block" : "Remove Time Off"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Plus } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
|
import type { EditableTimeOff, TimeOffActionSet } from "./time-off-form-sheet";
|
||||||
import { TimeOffFormSheet } from "./time-off-form-sheet";
|
import { TimeOffFormSheet, isPartialDay, minutesToHhmm } from "./time-off-form-sheet";
|
||||||
|
|
||||||
function formatRange(startDate: string, endDate: string): string {
|
function formatRange(startDate: string, endDate: string): string {
|
||||||
const start = new Date(`${startDate}T00:00:00`);
|
const start = new Date(`${startDate}T00:00:00`);
|
||||||
@@ -15,6 +15,11 @@ function formatRange(startDate: string, endDate: string): string {
|
|||||||
return `${startLabel} – ${endLabel}`;
|
return `${startLabel} – ${endLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatEntry(t: EditableTimeOff): string {
|
||||||
|
const range = formatRange(t.startDate, t.endDate);
|
||||||
|
return isPartialDay(t) ? `${range}, ${minutesToHhmm(t.startMinutes!)}–${minutesToHhmm(t.endMinutes!)}` : range;
|
||||||
|
}
|
||||||
|
|
||||||
export function TimeOffList({
|
export function TimeOffList({
|
||||||
upcoming,
|
upcoming,
|
||||||
past,
|
past,
|
||||||
@@ -61,7 +66,7 @@ export function TimeOffList({
|
|||||||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
|
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left hover:bg-stone"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-bold">{formatRange(t.startDate, t.endDate)}</p>
|
<p className="text-sm font-bold">{formatEntry(t)}</p>
|
||||||
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
|
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -77,7 +82,7 @@ export function TimeOffList({
|
|||||||
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
|
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
|
||||||
{past.map((t) => (
|
{past.map((t) => (
|
||||||
<li key={t.id} className="px-5 py-4">
|
<li key={t.id} className="px-5 py-4">
|
||||||
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
|
<p className="text-sm font-bold text-muted">{formatEntry(t)}</p>
|
||||||
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
|
{t.reason && <p className="text-xs text-muted">{t.reason}</p>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseCsv } from "../parse-csv";
|
||||||
|
import { escapeCsvCell, toCsv } from "../write-csv";
|
||||||
|
|
||||||
|
describe("escapeCsvCell", () => {
|
||||||
|
it("leaves plain text alone and renders empty values as blank", () => {
|
||||||
|
expect(escapeCsvCell("Jamie Fraser")).toBe("Jamie Fraser");
|
||||||
|
expect(escapeCsvCell(null)).toBe("");
|
||||||
|
expect(escapeCsvCell(undefined)).toBe("");
|
||||||
|
expect(escapeCsvCell("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders numbers, booleans and dates as text", () => {
|
||||||
|
expect(escapeCsvCell(12.5)).toBe("12.5");
|
||||||
|
expect(escapeCsvCell(false)).toBe("false");
|
||||||
|
expect(escapeCsvCell(new Date("2026-10-25T01:30:00Z"))).toBe("2026-10-25T01:30:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotes cells containing commas, quotes and line breaks, doubling inner quotes", () => {
|
||||||
|
expect(escapeCsvCell("Fraser, Jamie")).toBe('"Fraser, Jamie"');
|
||||||
|
expect(escapeCsvCell('Said "hello"')).toBe('"Said ""hello"""');
|
||||||
|
expect(escapeCsvCell("line one\nline two")).toBe('"line one\nline two"');
|
||||||
|
expect(escapeCsvCell("a\r\nb")).toBe('"a\r\nb"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["=SUM(A1:A9)", "+44 7700 900123", "-2+3", "@cmd", "\tindented", "\rcarriage"])(
|
||||||
|
"prefixes %j so a spreadsheet cannot run it as a formula",
|
||||||
|
(value) => {
|
||||||
|
expect(escapeCsvCell(value).startsWith("'") || escapeCsvCell(value).startsWith(`"'`)).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("prefixes before quoting, so a formula containing a comma is still neutralised", () => {
|
||||||
|
expect(escapeCsvCell('=HYPERLINK("http://evil.example","x")')).toBe(`"'=HYPERLINK(""http://evil.example"",""x"")"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch a formula character that is not the first character", () => {
|
||||||
|
expect(escapeCsvCell("a=b")).toBe("a=b");
|
||||||
|
expect(escapeCsvCell("Mon - Fri")).toBe("Mon - Fri");
|
||||||
|
expect(escapeCsvCell("o'brien")).toBe("o'brien");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toCsv", () => {
|
||||||
|
it("starts with a byte-order mark, separates records with CRLF and ends with a newline", () => {
|
||||||
|
expect(toCsv([["a", "b"], ["1", "2"]])).toBe("a,b\r\n1,2\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips through parseCsv, apart from the formula-guard apostrophe", () => {
|
||||||
|
const rows = [
|
||||||
|
["section", "value"],
|
||||||
|
["profile", 'Fraser, "Jamie"'],
|
||||||
|
["appointment", "line one\nline two"],
|
||||||
|
["appointment", "plain"],
|
||||||
|
["profile", ""],
|
||||||
|
["profile", "=1+1"],
|
||||||
|
];
|
||||||
|
expect(parseCsv(toCsv(rows))).toEqual([
|
||||||
|
["section", "value"],
|
||||||
|
["profile", 'Fraser, "Jamie"'],
|
||||||
|
["appointment", "line one\nline two"],
|
||||||
|
["appointment", "plain"],
|
||||||
|
["profile", ""],
|
||||||
|
["profile", "'=1+1"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* The writing counterpart of parse-csv.ts, used by the customer data export.
|
||||||
|
* Two jobs beyond joining cells with commas:
|
||||||
|
*
|
||||||
|
* - Quoting (RFC 4180): a cell containing a comma, a double quote or a line
|
||||||
|
* break is wrapped in quotes with inner quotes doubled — exactly what
|
||||||
|
* `parseCsv` reads back.
|
||||||
|
* - Formula-injection defence: a spreadsheet app treats a cell starting with
|
||||||
|
* `=`, `+`, `-` or `@` (and, in some apps, a tab or carriage return) as a
|
||||||
|
* formula, and a customer controls fields such as their name and booking
|
||||||
|
* notes. Those cells are prefixed with an apostrophe, which Excel, Sheets
|
||||||
|
* and Numbers show as plain text. Phone numbers that start with `+` get the
|
||||||
|
* same prefix: a visible apostrophe is a smaller cost than an exploitable
|
||||||
|
* export.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CsvCell = string | number | boolean | Date | null | undefined;
|
||||||
|
|
||||||
|
const FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]);
|
||||||
|
|
||||||
|
function cellToString(value: CsvCell): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
if (value instanceof Date) return value.toISOString();
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One cell, neutralised against formula injection and quoted when needed. */
|
||||||
|
export function escapeCsvCell(value: CsvCell): string {
|
||||||
|
let text = cellToString(value);
|
||||||
|
if (text.length > 0 && FORMULA_TRIGGERS.has(text[0]!)) text = `'${text}`;
|
||||||
|
if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A whole file: UTF-8 byte-order mark (so Excel reads accents correctly), CRLF record separators, trailing newline. */
|
||||||
|
export function toCsv(rows: CsvCell[][]): string {
|
||||||
|
return `${rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n")}\r\n`;
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
calendarDateOf,
|
calendarDateOf,
|
||||||
compareCalendarDates,
|
compareCalendarDates,
|
||||||
eachCalendarDate,
|
eachCalendarDate,
|
||||||
|
endOfBusinessDay,
|
||||||
isAfterCalendarDate,
|
isAfterCalendarDate,
|
||||||
isBeforeCalendarDate,
|
isBeforeCalendarDate,
|
||||||
minutesFromMidnight,
|
minutesFromMidnight,
|
||||||
|
startOfBusinessDay,
|
||||||
todayIn,
|
todayIn,
|
||||||
wallClockToInstant,
|
wallClockToInstant,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
@@ -96,3 +98,66 @@ describe("todayIn", () => {
|
|||||||
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
expect(todayIn("Europe/London")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The days themselves, not the days either side — the 2026-03 tests above
|
||||||
|
// never touched a 23h/25h day, which is how wallClockToInstant adding
|
||||||
|
// *elapsed* minutes to local midnight (an hour off on these days) went
|
||||||
|
// unnoticed. Europe/London: clocks go back 2026-10-25 02:00 -> 01:00
|
||||||
|
// (25-hour day) and forward 2027-03-28 01:00 -> 02:00 (23-hour day).
|
||||||
|
describe("clock-change days (Europe/London)", () => {
|
||||||
|
const zone = "Europe/London";
|
||||||
|
const hours = (from: Date, to: Date) => (to.getTime() - from.getTime()) / 3_600_000;
|
||||||
|
|
||||||
|
it("autumn change (25h day): a wall-clock time round-trips and lands on the right instant", () => {
|
||||||
|
const instant = wallClockToInstant("2026-10-25", 10 * 60, zone);
|
||||||
|
expect(instant.toISOString()).toBe("2026-10-25T10:00:00.000Z"); // GMT again by 10:00
|
||||||
|
expect(minutesFromMidnight(instant, zone)).toBe(10 * 60);
|
||||||
|
expect(calendarDateOf(instant, zone)).toBe("2026-10-25");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spring change (23h day): a wall-clock time round-trips and lands on the right instant", () => {
|
||||||
|
const instant = wallClockToInstant("2027-03-28", 10 * 60, zone);
|
||||||
|
expect(instant.toISOString()).toBe("2027-03-28T09:00:00.000Z"); // BST by 10:00
|
||||||
|
expect(minutesFromMidnight(instant, zone)).toBe(10 * 60);
|
||||||
|
expect(calendarDateOf(instant, zone)).toBe("2027-03-28");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every whole-hour wall-clock time from 09:00 to 18:00 round-trips on both days", () => {
|
||||||
|
for (const date of ["2026-10-25", "2027-03-28"]) {
|
||||||
|
for (let minutes = 9 * 60; minutes <= 18 * 60; minutes += 30) {
|
||||||
|
const instant = wallClockToInstant(date, minutes, zone);
|
||||||
|
expect(minutesFromMidnight(instant, zone)).toBe(minutes);
|
||||||
|
expect(calendarDateOf(instant, zone)).toBe(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a business day is 25 hours long when the clocks go back and 23 when they go forward", () => {
|
||||||
|
expect(hours(startOfBusinessDay("2026-10-25", zone), endOfBusinessDay("2026-10-25", zone))).toBe(25);
|
||||||
|
expect(hours(startOfBusinessDay("2027-03-28", zone), endOfBusinessDay("2027-03-28", zone))).toBe(23);
|
||||||
|
expect(hours(startOfBusinessDay("2026-10-24", zone), endOfBusinessDay("2026-10-24", zone))).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a day ends exactly where the next one starts, so no appointment falls in a gap or two days", () => {
|
||||||
|
for (const [date, next] of [
|
||||||
|
["2026-10-25", "2026-10-26"],
|
||||||
|
["2027-03-28", "2027-03-29"],
|
||||||
|
["2026-10-24", "2026-10-25"],
|
||||||
|
] as const) {
|
||||||
|
expect(endOfBusinessDay(date, zone).toISOString()).toBe(startOfBusinessDay(next, zone).toISOString());
|
||||||
|
}
|
||||||
|
expect(endOfBusinessDay("2026-10-25", zone).toISOString()).toBe("2026-10-26T00:00:00.000Z");
|
||||||
|
expect(endOfBusinessDay("2027-03-28", zone).toISOString()).toBe("2027-03-28T23:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the last evening hour of the 25h day still belongs to that day", () => {
|
||||||
|
const lateEvening = new Date("2026-10-25T23:30:00.000Z"); // 23:30 GMT
|
||||||
|
expect(calendarDateOf(lateEvening, zone)).toBe("2026-10-25");
|
||||||
|
expect(lateEvening < endOfBusinessDay("2026-10-25", zone)).toBe(true);
|
||||||
|
expect(lateEvening >= startOfBusinessDay("2026-10-25", zone)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("minutes beyond a day roll into the next calendar date at that date's own offset", () => {
|
||||||
|
expect(wallClockToInstant("2026-10-24", 24 * 60 + 9 * 60, zone).toISOString()).toBe("2026-10-25T09:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,9 +55,20 @@ export function calendarDateOf(instant: Date, zone: string): CalendarDate {
|
|||||||
* wall clock against the zone's actual offset for that specific date, not
|
* wall clock against the zone's actual offset for that specific date, not
|
||||||
* a fixed offset — the same "10:00" on either side of a DST transition
|
* a fixed offset — the same "10:00" on either side of a DST transition
|
||||||
* produces different UTC instants, correctly.
|
* produces different UTC instants, correctly.
|
||||||
|
*
|
||||||
|
* The wall-clock fields are SET, never added as elapsed minutes: adding
|
||||||
|
* 600 minutes to local midnight is "ten hours later", which on the day the
|
||||||
|
* clocks change is 09:00 or 11:00 on the wall — an hour off. Minutes past
|
||||||
|
* a day (1440 = the end of the day) roll into the next calendar date, whose
|
||||||
|
* own offset applies. A wall-clock time that doesn't exist (the skipped
|
||||||
|
* hour in spring) resolves to the first valid time after it.
|
||||||
*/
|
*/
|
||||||
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
|
export function wallClockToInstant(date: CalendarDate, minutesFromMidnightValue: number, zone: string): Date {
|
||||||
const dt = DateTime.fromISO(date, { zone }).startOf("day").plus({ minutes: minutesFromMidnightValue });
|
const dayOffset = Math.floor(minutesFromMidnightValue / (24 * 60));
|
||||||
|
const withinDay = minutesFromMidnightValue - dayOffset * 24 * 60;
|
||||||
|
const dt = DateTime.fromISO(date, { zone })
|
||||||
|
.plus({ days: dayOffset })
|
||||||
|
.set({ hour: Math.floor(withinDay / 60), minute: withinDay % 60, second: 0, millisecond: 0 });
|
||||||
return dt.toJSDate();
|
return dt.toJSDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseCsv } from "@/lib/csv/parse-csv";
|
||||||
|
import {
|
||||||
|
CUSTOMER_EXPORT_HEADER,
|
||||||
|
recordRows,
|
||||||
|
scrubTokens,
|
||||||
|
serializeCustomerExport,
|
||||||
|
stripQueryString,
|
||||||
|
} from "../customer-export-csv";
|
||||||
|
|
||||||
|
describe("stripQueryString", () => {
|
||||||
|
it("removes query strings and fragments, keeping the path", () => {
|
||||||
|
expect(stripQueryString("/appointments/abc123?token=SECRET")).toBe("/appointments/abc123");
|
||||||
|
expect(stripQueryString("/appointments/abc123#top")).toBe("/appointments/abc123");
|
||||||
|
expect(stripQueryString("/appointments")).toBe("/appointments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a missing link", () => {
|
||||||
|
expect(stripQueryString(null)).toBeNull();
|
||||||
|
expect(stripQueryString(undefined)).toBeNull();
|
||||||
|
expect(stripQueryString("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scrubTokens", () => {
|
||||||
|
it("blanks token values in links inside message text", () => {
|
||||||
|
const out = scrubTokens("Manage it: https://x.example/appointments/a1?token=SECRETVALUE&x=1 thanks");
|
||||||
|
expect(out).not.toContain("SECRETVALUE");
|
||||||
|
expect(out).toContain("thanks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes ordinary text and nulls through", () => {
|
||||||
|
expect(scrubTokens("See you at 10:00")).toBe("See you at 10:00");
|
||||||
|
expect(scrubTokens(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordRows / serializeCustomerExport", () => {
|
||||||
|
it("produces one row per field, sharing section, record and date", () => {
|
||||||
|
const date = new Date("2026-03-01T10:00:00Z");
|
||||||
|
const rows = recordRows("appointment", "appt_1", date, { status: "COMPLETED", staff: "Leo" });
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ section: "appointment", record: "appt_1", date, field: "status", value: "COMPLETED" },
|
||||||
|
{ section: "appointment", record: "appt_1", date, field: "staff", value: "Leo" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the header, then every row in order, and reads back with the project's CSV reader", () => {
|
||||||
|
const date = new Date("2026-03-01T10:00:00Z");
|
||||||
|
const rows = [
|
||||||
|
...recordRows("profile", "c1", date, { first_name: "=cmd|' /C calc'!A0", phone: "+44 7700 900123" }),
|
||||||
|
...recordRows("appointment", "a1", null, { customer_notes: 'Prefers "short, tidy"\nsides' }),
|
||||||
|
];
|
||||||
|
const parsed = parseCsv(serializeCustomerExport(rows));
|
||||||
|
|
||||||
|
expect(parsed[0]).toEqual([...CUSTOMER_EXPORT_HEADER]);
|
||||||
|
expect(parsed).toHaveLength(1 + rows.length);
|
||||||
|
expect(parsed[1]).toEqual(["profile", "c1", "2026-03-01T10:00:00.000Z", "first_name", "'=cmd|' /C calc'!A0"]);
|
||||||
|
expect(parsed[2]![4]).toBe("'+44 7700 900123");
|
||||||
|
expect(parsed[3]).toEqual(["appointment", "a1", "", "customer_notes", 'Prefers "short, tidy"\nsides']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { parseCsv } from "@/lib/csv/parse-csv";
|
||||||
|
import {
|
||||||
|
cleanupTestBusiness,
|
||||||
|
createTestAppointment,
|
||||||
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
|
createTestGuestCustomer,
|
||||||
|
createTestStaff,
|
||||||
|
prisma,
|
||||||
|
} from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||||
|
import { exportCustomerData } from "../customer-export.service";
|
||||||
|
|
||||||
|
const NOTE_SECRET = "PRIVATE-BARBER-NOTE-9f3a";
|
||||||
|
const PASSWORD_HASH = "bcrypt-hash-SECRET-77c1";
|
||||||
|
const PUSH_ENDPOINT = "https://push.example.test/endpoint-SECRET-52d9";
|
||||||
|
const PUSH_KEY = "p256dh-SECRET-1b8e";
|
||||||
|
const PUSH_AUTH = "auth-SECRET-6c40";
|
||||||
|
const GUEST_TOKEN = "guest-token-SECRET-aa21";
|
||||||
|
const OTP_HASH = "otp-hash-SECRET-4d7f";
|
||||||
|
const OAUTH_TOKEN = "oauth-access-SECRET-e5b3";
|
||||||
|
|
||||||
|
async function makeStaffWithPermission(business: TestBusiness, permission: "MANAGE_BUSINESS_PROFILE" | null) {
|
||||||
|
const staff = await createTestStaff(business);
|
||||||
|
if (permission) {
|
||||||
|
const role = await prisma.role.create({
|
||||||
|
data: { businessId: business.businessId, name: `Admin-${staff.staffId}`, permissions: { create: [{ permission }] } },
|
||||||
|
});
|
||||||
|
await prisma.staffProfile.update({ where: { id: staff.staffId }, data: { roleId: role.id } });
|
||||||
|
}
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsOf(csv: string) {
|
||||||
|
const [header, ...records] = parseCsv(csv);
|
||||||
|
expect(header).toEqual(["section", "record", "date", "field", "value"]);
|
||||||
|
return records.map(([section, record, date, field, value]) => ({ section, record, date, field, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exportCustomerData (integration)", () => {
|
||||||
|
let business: TestBusiness;
|
||||||
|
let otherBusiness: TestBusiness;
|
||||||
|
let extraProfileIds: string[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
business = await createTestBusiness();
|
||||||
|
otherBusiness = await createTestBusiness();
|
||||||
|
extraProfileIds = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.auditLog.deleteMany({ where: { action: "CUSTOMER_DATA_EXPORTED", entityType: "CustomerProfile" } }).catch(() => {});
|
||||||
|
await prisma.emailOtp.deleteMany({ where: { email: { startsWith: "export-otp-" } } }).catch(() => {});
|
||||||
|
await cleanupTestBusiness(business);
|
||||||
|
await cleanupTestBusiness(otherBusiness);
|
||||||
|
await prisma.customerProfile.deleteMany({ where: { id: { in: extraProfileIds } } }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A registered customer with a little of everything, including every kind of data that must never be exported. */
|
||||||
|
async function seedFullCustomer() {
|
||||||
|
const barber = await makeStaffWithPermission(business, null);
|
||||||
|
const customer = await createTestCustomer(business);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: customer.userId },
|
||||||
|
data: {
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
emailVerified: new Date("2026-01-02T09:00:00Z"),
|
||||||
|
phone: `+44 7700 ${Math.floor(100000 + Math.random() * 900000)}`,
|
||||||
|
phoneVerified: new Date("2026-01-03T09:00:00Z"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
userId: customer.userId,
|
||||||
|
type: "oauth",
|
||||||
|
provider: "google",
|
||||||
|
providerAccountId: `g-${customer.userId}`,
|
||||||
|
access_token: OAUTH_TOKEN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.pushSubscription.create({
|
||||||
|
data: {
|
||||||
|
businessId: business.businessId,
|
||||||
|
userId: customer.userId,
|
||||||
|
customerId: customer.customerId,
|
||||||
|
endpoint: `${PUSH_ENDPOINT}-${customer.userId}`,
|
||||||
|
p256dhKey: PUSH_KEY,
|
||||||
|
authKey: PUSH_AUTH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const email = (await prisma.user.findUniqueOrThrow({ where: { id: customer.userId } })).email!;
|
||||||
|
await prisma.emailOtp.create({
|
||||||
|
data: { email: `export-otp-${email}`, codeHash: OTP_HASH, purpose: "SIGN_IN", expiresAt: new Date(Date.now() + 60_000) },
|
||||||
|
});
|
||||||
|
await prisma.customerNote.create({
|
||||||
|
data: { customerId: customer.customerId, authorStaffId: barber.staffId, body: NOTE_SECRET },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { appointmentId } = await createTestAppointment(business, {
|
||||||
|
customerId: customer.customerId,
|
||||||
|
staffId: barber.staffId,
|
||||||
|
startAt: new Date("2026-11-02T10:00:00Z"),
|
||||||
|
});
|
||||||
|
await prisma.appointment.update({
|
||||||
|
where: { id: appointmentId },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
notes: "Please use the side door",
|
||||||
|
cancellationReason: "Feeling unwell",
|
||||||
|
cancelledAt: new Date("2026-11-01T08:00:00Z"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.appointmentService.createMany({
|
||||||
|
data: [
|
||||||
|
{ appointmentId, serviceId: business.haircutServiceId, priceAtBooking: 20, durationAtBookingMinutes: 30 },
|
||||||
|
{ appointmentId, serviceId: business.beardTrimServiceId, priceAtBooking: 10, durationAtBookingMinutes: 15 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await prisma.guestAppointmentAccessToken.create({
|
||||||
|
data: { appointmentId, tokenHash: GUEST_TOKEN, expiresAt: new Date(Date.now() + 86_400_000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// An appointment at the OTHER business must never appear.
|
||||||
|
const otherBarber = await makeStaffWithPermission(otherBusiness, null);
|
||||||
|
await createTestAppointment(otherBusiness, {
|
||||||
|
customerId: customer.customerId,
|
||||||
|
staffId: otherBarber.staffId,
|
||||||
|
startAt: new Date("2026-11-09T10:00:00Z"),
|
||||||
|
});
|
||||||
|
await prisma.appointment.updateMany({
|
||||||
|
where: { businessId: otherBusiness.businessId },
|
||||||
|
data: { notes: "OTHER-BUSINESS-APPOINTMENT" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const programme = await prisma.loyaltyProgramme.create({
|
||||||
|
data: { businessId: business.businessId, isEnabled: true, stampsRequired: 5 },
|
||||||
|
});
|
||||||
|
const account = await prisma.loyaltyAccount.create({
|
||||||
|
data: { customerId: customer.customerId, loyaltyProgrammeId: programme.id, currentStamps: 3 },
|
||||||
|
});
|
||||||
|
await prisma.loyaltyTransaction.create({
|
||||||
|
data: { loyaltyAccountId: account.id, type: "STAMP_ADDED", amount: 1, reason: "Visit", authoredByStaffId: barber.staffId },
|
||||||
|
});
|
||||||
|
await prisma.loyaltyReward.create({
|
||||||
|
data: { loyaltyAccountId: account.id, rewardType: "PERCENTAGE", value: 10, label: "10% off", status: "AVAILABLE" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
businessId: business.businessId,
|
||||||
|
recipientUserId: customer.userId,
|
||||||
|
recipientCustomerId: customer.customerId,
|
||||||
|
type: "BOOKING_CONFIRMED",
|
||||||
|
appointmentId,
|
||||||
|
subject: "Your booking is confirmed",
|
||||||
|
bodyText: "See you on 2 November",
|
||||||
|
deepLinkPath: `/appointments/${appointmentId}?token=LINKTOKEN-SECRET-93e0`,
|
||||||
|
idempotencyKey: `export-test-${customer.customerId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.notificationPreference.create({
|
||||||
|
data: { userId: customer.userId, type: "BOOKING_REMINDER", channel: "EMAIL", isEnabled: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...customer, barber, appointmentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("exports the customer's data completely and leaves out everything private", async () => {
|
||||||
|
const customer = await seedFullCustomer();
|
||||||
|
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
|
||||||
|
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: admin.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: customer.customerId,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
expect(result.filename).toMatch(/^customer-data-test-customer-\d{4}-\d{2}-\d{2}\.csv$/);
|
||||||
|
|
||||||
|
// Nothing private, anywhere in the file.
|
||||||
|
for (const secret of [
|
||||||
|
NOTE_SECRET,
|
||||||
|
PASSWORD_HASH,
|
||||||
|
PUSH_ENDPOINT,
|
||||||
|
PUSH_KEY,
|
||||||
|
PUSH_AUTH,
|
||||||
|
GUEST_TOKEN,
|
||||||
|
OTP_HASH,
|
||||||
|
OAUTH_TOKEN,
|
||||||
|
"LINKTOKEN-SECRET",
|
||||||
|
"OTHER-BUSINESS-APPOINTMENT",
|
||||||
|
]) {
|
||||||
|
expect(result.csv).not.toContain(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = rowsOf(result.csv);
|
||||||
|
const value = (section: string, field: string, record?: string) =>
|
||||||
|
rows.find((r) => r.section === section && r.field === field && (record === undefined || r.record === record))?.value;
|
||||||
|
|
||||||
|
// Profile and linked account, including verified dates.
|
||||||
|
expect(value("profile", "first_name")).toBe("Test");
|
||||||
|
expect(value("profile", "last_name")).toBe("Customer");
|
||||||
|
expect(value("profile", "registered_customer")).toBe("true");
|
||||||
|
expect(value("account", "email_verified_at")).toBe("2026-01-02T09:00:00.000Z");
|
||||||
|
expect(value("account", "phone_verified_at")).toBe("2026-01-03T09:00:00.000Z");
|
||||||
|
expect(value("account", "phone")).toMatch(/^'\+44 7700 /); // formula-guard prefix on a leading "+"
|
||||||
|
|
||||||
|
// The appointment, with the customer's own notes and the cancellation reason — one appointment, this business only.
|
||||||
|
expect(rows.filter((r) => r.section === "appointment" && r.field === "status")).toHaveLength(1);
|
||||||
|
expect(value("appointment", "status", customer.appointmentId)).toBe("CANCELLED");
|
||||||
|
expect(value("appointment", "services", customer.appointmentId)).toBe("Haircut + Beard Trim");
|
||||||
|
expect(value("appointment", "staff", customer.appointmentId)).toBe("Test Barber");
|
||||||
|
expect(value("appointment", "price", customer.appointmentId)).toBe("30.00");
|
||||||
|
expect(value("appointment", "customer_notes", customer.appointmentId)).toBe("Please use the side door");
|
||||||
|
expect(value("appointment", "cancellation_reason", customer.appointmentId)).toBe("Feeling unwell");
|
||||||
|
|
||||||
|
// Loyalty.
|
||||||
|
expect(value("loyalty_account", "current_stamps")).toBe("3");
|
||||||
|
expect(value("loyalty_transaction", "type")).toBe("STAMP_ADDED");
|
||||||
|
expect(value("loyalty_transaction", "reason")).toBe("Visit");
|
||||||
|
expect(value("loyalty_reward", "label")).toBe("10% off");
|
||||||
|
|
||||||
|
// Notifications sent: subject and body, link without its query string.
|
||||||
|
expect(value("notification", "subject")).toBe("Your booking is confirmed");
|
||||||
|
expect(value("notification", "message")).toBe("See you on 2 November");
|
||||||
|
expect(value("notification", "link")).toBe(`/appointments/${customer.appointmentId}`);
|
||||||
|
|
||||||
|
// Preferences.
|
||||||
|
const prefFields = rows.filter((r) => r.section === "notification_preference");
|
||||||
|
expect(prefFields.map((r) => [r.field, r.value])).toEqual([
|
||||||
|
["type", "BOOKING_REMINDER"],
|
||||||
|
["channel", "EMAIL"],
|
||||||
|
["enabled", "false"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a CUSTOMER_DATA_EXPORTED audit entry without any of the exported data in it", async () => {
|
||||||
|
const customer = await seedFullCustomer();
|
||||||
|
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
|
||||||
|
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: admin.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: customer.customerId,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const entries = await prisma.auditLog.findMany({ where: { action: "CUSTOMER_DATA_EXPORTED", entityId: customer.customerId } });
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]).toMatchObject({ actorUserId: admin.userId, entityType: "CustomerProfile" });
|
||||||
|
expect(JSON.stringify(entries[0]!.metadata)).not.toContain("Feeling unwell");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports a guest customer (no linked account) without account or preference sections", async () => {
|
||||||
|
const barber = await makeStaffWithPermission(business, null);
|
||||||
|
const guest = await createTestGuestCustomer();
|
||||||
|
extraProfileIds.push(guest.customerId);
|
||||||
|
await createTestAppointment(business, { customerId: guest.customerId, staffId: barber.staffId });
|
||||||
|
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
|
||||||
|
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: admin.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: guest.customerId,
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
const sections = new Set(rowsOf(result.csv).map((r) => r.section));
|
||||||
|
expect(sections).toEqual(new Set(["profile", "appointment"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("permission and scope", () => {
|
||||||
|
it("refuses a barber without MANAGE_BUSINESS_PROFILE, even though they can open the client page, and records nothing", async () => {
|
||||||
|
const customer = await seedFullCustomer();
|
||||||
|
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: customer.barber.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: customer.customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, reason: "FORBIDDEN" });
|
||||||
|
expect(await prisma.auditLog.count({ where: { action: "CUSTOMER_DATA_EXPORTED", entityId: customer.customerId } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a customer (non-staff) user", async () => {
|
||||||
|
const customer = await seedFullCustomer();
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: customer.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: customer.customerId,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: false, reason: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an administrator of a different business", async () => {
|
||||||
|
const customer = await seedFullCustomer();
|
||||||
|
const outsider = await makeStaffWithPermission(otherBusiness, "MANAGE_BUSINESS_PROFILE");
|
||||||
|
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: outsider.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: customer.customerId,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: false, reason: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns NOT_FOUND for a customer who is not one of this business's clients", async () => {
|
||||||
|
const stranger = await createTestGuestCustomer(); // a guest with no booking here
|
||||||
|
extraProfileIds.push(stranger.customerId);
|
||||||
|
const admin = await makeStaffWithPermission(business, "MANAGE_BUSINESS_PROFILE");
|
||||||
|
|
||||||
|
const result = await exportCustomerData(prisma, {
|
||||||
|
actorUserId: admin.userId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId: stranger.customerId,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: false, reason: "NOT_FOUND" });
|
||||||
|
expect(await prisma.auditLog.count({ where: { action: "CUSTOMER_DATA_EXPORTED", entityId: stranger.customerId } })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { toCsv } from "@/lib/csv/write-csv";
|
||||||
|
import type { CsvCell } from "@/lib/csv/write-csv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The database-free half of the customer data export: the row shape, the
|
||||||
|
* helpers that build and sanitise rows, and the CSV serialiser. Split out of
|
||||||
|
* customer-export.service.ts so it can be unit tested without a database —
|
||||||
|
* see that file for what the export contains and, more importantly, what it
|
||||||
|
* must never contain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CUSTOMER_EXPORT_HEADER = ["section", "record", "date", "field", "value"] as const;
|
||||||
|
|
||||||
|
export interface CustomerExportRow {
|
||||||
|
section: string;
|
||||||
|
record: string;
|
||||||
|
date: Date | null;
|
||||||
|
field: string;
|
||||||
|
value: CsvCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Fields = Record<string, CsvCell>;
|
||||||
|
|
||||||
|
/** Turns `{field: value}` pairs into rows of one record. Empty values are kept (as blank cells) so the shape of every record is the same. */
|
||||||
|
export function recordRows(section: string, record: string, date: Date | null, fields: Fields): CustomerExportRow[] {
|
||||||
|
return Object.entries(fields).map(([field, value]) => ({ section, record, date, field, value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notification links carry single-purpose access tokens in their query string — drop everything after the path. */
|
||||||
|
export function stripQueryString(path: string | null | undefined): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
return path.split(/[?#]/)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A link with a token could also appear inside the message text; blank out any `token=…` value. */
|
||||||
|
export function scrubTokens(text: string | null | undefined): string | null {
|
||||||
|
if (text == null) return null;
|
||||||
|
return text.replace(/([?&]token=)[^\s&"'<>]+/gi, "$1[removed]");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCustomerExport(rows: CustomerExportRow[]): string {
|
||||||
|
return toCsv([
|
||||||
|
[...CUSTOMER_EXPORT_HEADER],
|
||||||
|
...rows.map((r) => [r.section, r.record, r.date, r.field, r.value]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import type { PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
|
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||||
|
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
|
||||||
|
import { clientDirectoryWhere } from "./client-directory";
|
||||||
|
import { recordRows, scrubTokens, serializeCustomerExport, stripQueryString } from "./customer-export-csv";
|
||||||
|
import type { CustomerExportRow } from "./customer-export-csv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One customer's personal data as a single CSV — the "give me everything you
|
||||||
|
* hold on me" request, run by the business owner on the customer's behalf.
|
||||||
|
*
|
||||||
|
* Long format with a `section` column (profile, account, appointment,
|
||||||
|
* loyalty_*, notification, notification_preference): every row is one field
|
||||||
|
* of one record, so very different record shapes share one header and the
|
||||||
|
* file still opens as an ordinary spreadsheet. `record` groups the fields of
|
||||||
|
* one item (an appointment id, a notification id); `date` is the record's own
|
||||||
|
* timestamp.
|
||||||
|
*
|
||||||
|
* What is deliberately NEVER read here (not just filtered afterwards — the
|
||||||
|
* queries below use explicit `select`s, so a new column added to User or
|
||||||
|
* Notification later does not leak into exports by accident):
|
||||||
|
* - CustomerNote — the barbers' private notes about the client.
|
||||||
|
* - PushSubscription endpoints/keys, passwordHash, OAuth Account/Session
|
||||||
|
* tokens, EmailOtp codes, GuestAppointmentAccessToken, AuditLog.
|
||||||
|
* - Which staff member authored a loyalty entry, internal ids of staff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExportCustomerDataResult =
|
||||||
|
| { ok: true; csv: string; filename: string; rowCount: number }
|
||||||
|
| { ok: false; reason: "FORBIDDEN" | "NOT_FOUND" };
|
||||||
|
|
||||||
|
export async function buildCustomerExportRows(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { customerId: string; businessId: string },
|
||||||
|
): Promise<CustomerExportRow[] | null> {
|
||||||
|
const { customerId, businessId } = params;
|
||||||
|
|
||||||
|
const profile = await prisma.customerProfile.findFirst({
|
||||||
|
where: { AND: [{ id: customerId }, clientDirectoryWhere(businessId)] },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
isGuest: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
preferredBarber: { select: { user: { select: { name: true } } } },
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
phone: true,
|
||||||
|
phoneVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!profile) return null;
|
||||||
|
|
||||||
|
const [appointments, loyaltyAccounts, notifications, preferences] = await Promise.all([
|
||||||
|
prisma.appointment.findMany({
|
||||||
|
where: { customerId, businessId },
|
||||||
|
orderBy: { startAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
startAt: true,
|
||||||
|
endAt: true,
|
||||||
|
notes: true,
|
||||||
|
discountAmount: true,
|
||||||
|
cancellationReason: true,
|
||||||
|
cancelledAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
staff: { select: { user: { select: { name: true } } } },
|
||||||
|
services: { select: { priceAtBooking: true, service: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.loyaltyAccount.findMany({
|
||||||
|
where: { customerId, loyaltyProgramme: { businessId } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
currentStamps: true,
|
||||||
|
createdAt: true,
|
||||||
|
transactions: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true, type: true, amount: true, reason: true, createdAt: true },
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
label: true,
|
||||||
|
rewardType: true,
|
||||||
|
value: true,
|
||||||
|
status: true,
|
||||||
|
expiresAt: true,
|
||||||
|
redeemedAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
OR: [{ recipientCustomerId: customerId }, ...(profile.user ? [{ recipientUserId: profile.user.id }] : [])],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true, type: true, subject: true, bodyText: true, deepLinkPath: true, readAt: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
profile.user
|
||||||
|
? prisma.notificationPreference.findMany({
|
||||||
|
where: { userId: profile.user.id },
|
||||||
|
orderBy: [{ type: "asc" }, { channel: "asc" }],
|
||||||
|
select: { id: true, type: true, channel: true, isEnabled: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows: CustomerExportRow[] = [];
|
||||||
|
|
||||||
|
rows.push(
|
||||||
|
...recordRows("profile", profile.id, profile.createdAt, {
|
||||||
|
first_name: profile.firstName,
|
||||||
|
last_name: profile.lastName,
|
||||||
|
email: profile.email,
|
||||||
|
phone: profile.phone,
|
||||||
|
registered_customer: !profile.isGuest,
|
||||||
|
preferred_barber: profile.preferredBarber?.user.name ?? null,
|
||||||
|
last_updated: profile.updatedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profile.user) {
|
||||||
|
rows.push(
|
||||||
|
...recordRows("account", profile.user.id, profile.user.createdAt, {
|
||||||
|
name: profile.user.name,
|
||||||
|
email: profile.user.email,
|
||||||
|
email_verified_at: profile.user.emailVerified,
|
||||||
|
phone: profile.user.phone,
|
||||||
|
phone_verified_at: profile.user.phoneVerified,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of appointments) {
|
||||||
|
const subtotal = a.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||||
|
const discount = a.discountAmount ? Number(a.discountAmount) : 0;
|
||||||
|
rows.push(
|
||||||
|
...recordRows("appointment", a.id, a.startAt, {
|
||||||
|
status: a.status,
|
||||||
|
starts_at: a.startAt,
|
||||||
|
ends_at: a.endAt,
|
||||||
|
services: a.services.map((s) => s.service.name).join(" + "),
|
||||||
|
staff: a.staff.user.name,
|
||||||
|
price: (subtotal - discount).toFixed(2),
|
||||||
|
discount: a.discountAmount ? discount.toFixed(2) : null,
|
||||||
|
customer_notes: a.notes,
|
||||||
|
cancellation_reason: a.cancellationReason,
|
||||||
|
cancelled_at: a.cancelledAt,
|
||||||
|
booked_at: a.createdAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const account of loyaltyAccounts) {
|
||||||
|
rows.push(
|
||||||
|
...recordRows("loyalty_account", account.id, account.createdAt, { current_stamps: account.currentStamps }),
|
||||||
|
);
|
||||||
|
for (const t of account.transactions) {
|
||||||
|
rows.push(
|
||||||
|
...recordRows("loyalty_transaction", t.id, t.createdAt, { type: t.type, amount: t.amount, reason: t.reason }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const r of account.rewards) {
|
||||||
|
rows.push(
|
||||||
|
...recordRows("loyalty_reward", r.id, r.createdAt, {
|
||||||
|
label: r.label,
|
||||||
|
reward_type: r.rewardType,
|
||||||
|
value: r.value ? Number(r.value).toFixed(2) : null,
|
||||||
|
status: r.status,
|
||||||
|
expires_at: r.expiresAt,
|
||||||
|
redeemed_at: r.redeemedAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of notifications) {
|
||||||
|
rows.push(
|
||||||
|
...recordRows("notification", n.id, n.createdAt, {
|
||||||
|
type: n.type,
|
||||||
|
subject: scrubTokens(n.subject),
|
||||||
|
message: scrubTokens(n.bodyText),
|
||||||
|
link: stripQueryString(n.deepLinkPath),
|
||||||
|
read_at: n.readAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of preferences) {
|
||||||
|
rows.push(
|
||||||
|
...recordRows("notification_preference", p.id, null, {
|
||||||
|
type: p.type,
|
||||||
|
channel: p.channel,
|
||||||
|
enabled: p.isEnabled,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filenameFor(firstName: string, lastName: string, now: Date): string {
|
||||||
|
const slug = `${firstName}-${lastName}`
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^\w-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
return `customer-data-${slug || "export"}-${now.toISOString().slice(0, 10)}.csv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole operation behind the export route: checks the caller may export
|
||||||
|
* (MANAGE_BUSINESS_PROFILE — the same bar as the client import — and that they
|
||||||
|
* work for `businessId`; a Super Administrator may act on any), builds the CSV
|
||||||
|
* and records an audit entry. The permission check lives here, not in the
|
||||||
|
* route, so no caller can reach the data without it; and it is independent of
|
||||||
|
* who can open the client's detail page (any staff member).
|
||||||
|
*/
|
||||||
|
export async function exportCustomerData(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
params: { actorUserId: string; businessId: string; customerId: string; now?: Date },
|
||||||
|
): Promise<ExportCustomerDataResult> {
|
||||||
|
const { actorUserId, businessId, customerId } = params;
|
||||||
|
|
||||||
|
if (!(await hasPermission(actorUserId, Permission.MANAGE_BUSINESS_PROFILE))) {
|
||||||
|
return { ok: false, reason: "FORBIDDEN" };
|
||||||
|
}
|
||||||
|
if (!(await isSuperAdmin(actorUserId))) {
|
||||||
|
const staff = await prisma.staffProfile.findUnique({ where: { userId: actorUserId }, select: { businessId: true } });
|
||||||
|
if (staff?.businessId !== businessId) return { ok: false, reason: "FORBIDDEN" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await buildCustomerExportRows(prisma, { customerId, businessId });
|
||||||
|
if (!rows) return { ok: false, reason: "NOT_FOUND" };
|
||||||
|
|
||||||
|
await writeAuditLog(prisma, {
|
||||||
|
actorUserId,
|
||||||
|
action: "CUSTOMER_DATA_EXPORTED",
|
||||||
|
entityType: "CustomerProfile",
|
||||||
|
entityId: customerId,
|
||||||
|
metadata: { businessId, rowCount: rows.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = rows.find((r) => r.section === "profile" && r.field === "first_name")?.value;
|
||||||
|
const last = rows.find((r) => r.section === "profile" && r.field === "last_name")?.value;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
csv: serializeCustomerExport(rows),
|
||||||
|
filename: filenameFor(String(first ?? ""), String(last ?? ""), params.now ?? new Date()),
|
||||||
|
rowCount: rows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { Weekday } from "@/generated/prisma/enums";
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
calendarDateOf,
|
||||||
|
endOfBusinessDay,
|
||||||
|
minutesFromMidnight,
|
||||||
|
startOfBusinessDay,
|
||||||
|
todayIn,
|
||||||
|
wallClockToInstant,
|
||||||
|
} from "@/lib/time/business-time";
|
||||||
|
import type { CalendarDate } from "@/lib/time/business-time";
|
||||||
|
import {
|
||||||
|
getAvailableDates,
|
||||||
|
getAvailableTimeSlots,
|
||||||
|
validateBookingSlot,
|
||||||
|
} from "../availability.service";
|
||||||
|
import { createBooking } from "../booking.service";
|
||||||
|
import {
|
||||||
|
cleanupTestBusiness,
|
||||||
|
createTestBusiness,
|
||||||
|
createTestCustomer,
|
||||||
|
createTestStaff,
|
||||||
|
prisma,
|
||||||
|
} from "./fixtures";
|
||||||
|
import type { TestBusiness } from "./fixtures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next days (within the booking window) on which the clocks change in
|
||||||
|
* `zone` — found from the zone's own rules, so this keeps testing real
|
||||||
|
* 23h/25h days whenever it runs instead of going stale after a fixed date.
|
||||||
|
*/
|
||||||
|
function upcomingClockChangeDays(zone: string, count: number): CalendarDate[] {
|
||||||
|
const found: CalendarDate[] = [];
|
||||||
|
let date = addDays(todayIn(zone), 1);
|
||||||
|
for (let i = 0; i < 365 && found.length < count; i++) {
|
||||||
|
const hoursInDay =
|
||||||
|
(DateTime.fromISO(addDays(date, 1), { zone }).toMillis() -
|
||||||
|
DateTime.fromISO(date, { zone }).toMillis()) /
|
||||||
|
3_600_000;
|
||||||
|
if (hoursInDay !== 24) found.push(date);
|
||||||
|
date = addDays(date, 1);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_DAYS = [
|
||||||
|
Weekday.MONDAY,
|
||||||
|
Weekday.TUESDAY,
|
||||||
|
Weekday.WEDNESDAY,
|
||||||
|
Weekday.THURSDAY,
|
||||||
|
Weekday.FRIDAY,
|
||||||
|
Weekday.SATURDAY,
|
||||||
|
Weekday.SUNDAY,
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("booking on the days the clocks change (integration)", () => {
|
||||||
|
let business: TestBusiness;
|
||||||
|
let staffId: string;
|
||||||
|
let customerId: string;
|
||||||
|
let changeDays: CalendarDate[];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
business = await createTestBusiness();
|
||||||
|
// Clock changes fall on a Sunday in Europe/London, which the fixture business keeps closed.
|
||||||
|
await prisma.businessHours.updateMany({
|
||||||
|
where: { businessId: business.businessId },
|
||||||
|
data: { isClosed: false, opensAtMinutes: 9 * 60, closesAtMinutes: 18 * 60 },
|
||||||
|
});
|
||||||
|
staffId = (await createTestStaff(business, { workingDays: ALL_DAYS })).staffId;
|
||||||
|
customerId = (await createTestCustomer(business)).customerId;
|
||||||
|
changeDays = upcomingClockChangeDays(business.timezone, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await cleanupTestBusiness(business);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds both an autumn (25h) and a spring (23h) change in the next year", () => {
|
||||||
|
expect(changeDays).toHaveLength(2);
|
||||||
|
const lengths = changeDays.map(
|
||||||
|
(d) =>
|
||||||
|
(endOfBusinessDay(d, business.timezone).getTime() -
|
||||||
|
startOfBusinessDay(d, business.timezone).getTime()) /
|
||||||
|
3_600_000,
|
||||||
|
);
|
||||||
|
expect(lengths.sort()).toEqual([23, 25]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers slots whose instants match the wall-clock time shown, on both changes", async () => {
|
||||||
|
for (const date of changeDays) {
|
||||||
|
const { slots, validation } = await getAvailableTimeSlots(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
date,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validation.valid).toBe(true);
|
||||||
|
expect(slots[0]!.startMinutes).toBe(9 * 60);
|
||||||
|
for (const slot of slots) {
|
||||||
|
const startAt = new Date(slot.startAt);
|
||||||
|
expect(minutesFromMidnight(startAt, business.timezone)).toBe(slot.startMinutes);
|
||||||
|
expect(calendarDateOf(startAt, business.timezone)).toBe(date);
|
||||||
|
// A 30-minute haircut is 30 real minutes long.
|
||||||
|
expect(new Date(slot.endAt).getTime() - startAt.getTime()).toBe(30 * 60_000);
|
||||||
|
}
|
||||||
|
const { dates } = await getAvailableDates(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
fromDate: date,
|
||||||
|
toDate: date,
|
||||||
|
});
|
||||||
|
expect(dates).toEqual([date]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("books 10:00 at exactly 10:00 local time and keeps the booking's length, on both changes", async () => {
|
||||||
|
for (const date of changeDays) {
|
||||||
|
const startAt = wallClockToInstant(date, 10 * 60, business.timezone);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await validateBookingSlot(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
staffId,
|
||||||
|
startAt,
|
||||||
|
})
|
||||||
|
).validation.valid,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const result = await createBooking(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId,
|
||||||
|
staffId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
startAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (!result.success) return;
|
||||||
|
expect(minutesFromMidnight(result.appointment.startAt, business.timezone)).toBe(10 * 60);
|
||||||
|
expect(calendarDateOf(result.appointment.startAt, business.timezone)).toBe(date);
|
||||||
|
expect(result.appointment.endAt.getTime() - result.appointment.startAt.getTime()).toBe(
|
||||||
|
30 * 60_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an evening booking on the 25h day still counts as that day, and is not double-booked", async () => {
|
||||||
|
const date = changeDays.find(
|
||||||
|
(d) =>
|
||||||
|
endOfBusinessDay(d, business.timezone).getTime() -
|
||||||
|
startOfBusinessDay(d, business.timezone).getTime() ===
|
||||||
|
25 * 3_600_000,
|
||||||
|
)!;
|
||||||
|
const startAt = wallClockToInstant(date, 17 * 60 + 30, business.timezone);
|
||||||
|
|
||||||
|
const first = await createBooking(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
customerId,
|
||||||
|
staffId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
startAt,
|
||||||
|
});
|
||||||
|
expect(first.success).toBe(true);
|
||||||
|
|
||||||
|
const second = await validateBookingSlot(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
staffId,
|
||||||
|
startAt,
|
||||||
|
});
|
||||||
|
expect(second.validation.valid).toBe(false);
|
||||||
|
|
||||||
|
const { slots } = await getAvailableTimeSlots(prisma, {
|
||||||
|
businessId: business.businessId,
|
||||||
|
serviceIds: [business.haircutServiceId],
|
||||||
|
date,
|
||||||
|
});
|
||||||
|
expect(slots.map((s) => s.startMinutes)).not.toContain(17 * 60 + 30);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
prisma,
|
prisma,
|
||||||
} from "./fixtures";
|
} from "./fixtures";
|
||||||
import type { TestBusiness } from "./fixtures";
|
import type { TestBusiness } from "./fixtures";
|
||||||
|
import { PermissionDeniedError, assertCanAccessAppointment } from "@/features/booking/authorization";
|
||||||
|
import { getAppointmentById } from "../appointment.service";
|
||||||
import {
|
import {
|
||||||
customerAppointmentLink,
|
customerAppointmentLink,
|
||||||
deriveGuestAccessToken,
|
deriveGuestAccessToken,
|
||||||
@@ -81,4 +83,50 @@ describe("guest access links (integration)", () => {
|
|||||||
const { userId } = await createTestCustomer(business);
|
const { userId } = await createTestCustomer(business);
|
||||||
expect(customerAppointmentLink({ id: appointmentId, customer: { userId } })).toBe(`/appointments/${appointmentId}`);
|
expect(customerAppointmentLink({ id: appointmentId, customer: { userId } })).toBe(`/appointments/${appointmentId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("assertCanAccessAppointment on a guest appointment", () => {
|
||||||
|
it("an appointment id alone is rejected — no token, an empty token, a guessed token, or someone else's session", async () => {
|
||||||
|
const { appointmentId } = await guestAppointment();
|
||||||
|
const appointment = (await getAppointmentById(prisma, appointmentId))!;
|
||||||
|
const otherUser = await createTestCustomer(business);
|
||||||
|
|
||||||
|
for (const access of [
|
||||||
|
{},
|
||||||
|
{ guestToken: "" },
|
||||||
|
{ guestToken: "not-the-token" },
|
||||||
|
{ sessionUserId: otherUser.userId },
|
||||||
|
{ sessionUserId: otherUser.userId, guestToken: "not-the-token" },
|
||||||
|
]) {
|
||||||
|
await expect(assertCanAccessAppointment(prisma, appointment, access)).rejects.toThrow(PermissionDeniedError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("one appointment's token does not open a different guest appointment", async () => {
|
||||||
|
const first = await guestAppointment();
|
||||||
|
const second = await guestAppointment();
|
||||||
|
const secondAppointment = (await getAppointmentById(prisma, second.appointmentId))!;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, secondAppointment, { guestToken: first.bookingToken }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, secondAppointment, { guestToken: deriveGuestAccessToken(first.appointmentId) }),
|
||||||
|
).rejects.toThrow(PermissionDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the booking-time token and the derived link token both open it; a revoked appointment opens for neither", async () => {
|
||||||
|
const { appointmentId, bookingToken } = await guestAppointment();
|
||||||
|
const appointment = (await getAppointmentById(prisma, appointmentId))!;
|
||||||
|
|
||||||
|
await expect(assertCanAccessAppointment(prisma, appointment, { guestToken: bookingToken })).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
assertCanAccessAppointment(prisma, appointment, { guestToken: deriveGuestAccessToken(appointmentId) }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await revokeGuestAccessToken(prisma, appointmentId);
|
||||||
|
await expect(assertCanAccessAppointment(prisma, appointment, { guestToken: bookingToken })).rejects.toThrow(
|
||||||
|
PermissionDeniedError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -58,6 +58,10 @@ export interface ListAppointmentsParams {
|
|||||||
fromDate?: CalendarDate;
|
fromDate?: CalendarDate;
|
||||||
toDate?: CalendarDate;
|
toDate?: CalendarDate;
|
||||||
businessTimezone?: string; // required if fromDate/toDate given
|
businessTimezone?: string; // required if fromDate/toDate given
|
||||||
|
/** Sort by start time. Defaults to "asc" — staff agendas and reminders rely on it. */
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
/** Cap the number of rows returned (for paged lists). */
|
||||||
|
take?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
|
export async function listAppointments(prisma: PrismaClient, params: ListAppointmentsParams) {
|
||||||
@@ -80,7 +84,8 @@ export async function listAppointments(prisma: PrismaClient, params: ListAppoint
|
|||||||
...dateFilter,
|
...dateFilter,
|
||||||
},
|
},
|
||||||
include: APPOINTMENT_INCLUDE,
|
include: APPOINTMENT_INCLUDE,
|
||||||
orderBy: { startAt: "asc" },
|
orderBy: [{ startAt: params.order ?? "asc" }, { id: "asc" }],
|
||||||
|
...(params.take !== undefined ? { take: params.take } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -398,7 +398,8 @@ export async function getAvailableTimeSlots(
|
|||||||
startMinutes,
|
startMinutes,
|
||||||
endMinutes,
|
endMinutes,
|
||||||
startAt: startAt.toISOString(),
|
startAt: startAt.toISOString(),
|
||||||
endAt: wallClockToInstant(params.date, endMinutes, context.business.timezone).toISOString(),
|
// Elapsed, not wall-clock: a service is a real number of minutes long, even across a clock change.
|
||||||
|
endAt: new Date(startAt.getTime() + context.totalDurationMinutes * 60_000).toISOString(),
|
||||||
staffOptions: [staffId],
|
staffOptions: [staffId],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -452,7 +453,9 @@ export async function validateBookingSlot(
|
|||||||
const date = calendarDateOf(params.startAt, timezone);
|
const date = calendarDateOf(params.startAt, timezone);
|
||||||
const startMinutes = minutesFromMidnight(params.startAt, timezone);
|
const startMinutes = minutesFromMidnight(params.startAt, timezone);
|
||||||
const endMinutes = startMinutes + context.totalDurationMinutes;
|
const endMinutes = startMinutes + context.totalDurationMinutes;
|
||||||
const endAt = wallClockToInstant(date, endMinutes, timezone);
|
// Elapsed, not wall-clock: the booking lasts exactly the services' duration even across a clock change.
|
||||||
|
// `endMinutes` stays wall-clock — it is only compared against business hours and breaks, which are.
|
||||||
|
const endAt = new Date(params.startAt.getTime() + context.totalDurationMinutes * 60_000);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = todayIn(timezone);
|
const today = todayIn(timezone);
|
||||||
|
|
||||||
|
|||||||
@@ -478,6 +478,8 @@ export function validateTimeOffInput(input: TimeOffInput): ValidationResult<Staf
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
!Number.isInteger(input.startMinutes) ||
|
||||||
|
!Number.isInteger(input.endMinutes) ||
|
||||||
input.startMinutes! < 0 ||
|
input.startMinutes! < 0 ||
|
||||||
input.endMinutes! > 24 * 60 ||
|
input.endMinutes! > 24 * 60 ||
|
||||||
input.startMinutes! >= input.endMinutes!
|
input.startMinutes! >= input.endMinutes!
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
# FALLBACK ONLY. The Manager's built-in scheduler (manager/src/instrumentation.ts,
|
||||||
|
# see manager/README.md) now calls these endpoints itself — every 30 s by
|
||||||
|
# default, configurable — so with the Manager running you do NOT need a
|
||||||
|
# crontab entry for this script. Keep it for a deployment where the
|
||||||
|
# scheduler is disabled (MANAGER_SCHEDULER_DISABLED=1); never run both.
|
||||||
|
#
|
||||||
# Calls one of the app's CRON_SECRET-gated endpoints (see
|
# Calls one of the app's CRON_SECRET-gated endpoints (see
|
||||||
# src/app/api/cron/process-notifications/route.ts and
|
# src/app/api/cron/process-notifications/route.ts and
|
||||||
# src/app/api/cron/sync-google-reviews/route.ts) for every business
|
# src/app/api/cron/sync-google-reviews/route.ts) for every business
|
||||||
|
|||||||
Reference in new issue
Block a user