diff --git a/docs/phase-4-summary.md b/docs/phase-4-summary.md
index bd8c04a..db02b77 100644
--- a/docs/phase-4-summary.md
+++ b/docs/phase-4-summary.md
@@ -69,7 +69,7 @@ Documented in full in `src/services/customer/customer.service.ts`; summary:
2. A guest caller is matched against an existing profile only if **both** email (case-insensitive, normalised in application code — not Prisma's `mode: "insensitive"`, which is Postgres/Mongo-only and would break on a MySQL swap) **and** phone match exactly, against `isGuest = true AND userId IS NULL` profiles only.
3. A guest is never matched against a registered customer's profile, even on an exact email match — that would attach a guest's booking to a real account without verification.
4. No match → a new guest profile is created. Existing-profile fields are never overwritten on a match.
-5. **Guest appointment access is "order tracking" by unguessable id**, not by verified identity — no magic-link/OTP system was built this phase. This is a deliberate, documented scope limitation (see §12), not an oversight.
+5. ~~**Guest appointment access is "order tracking" by unguessable id**, not by verified identity~~ — *superseded in Phase 7.* A guest appointment now needs a single-purpose access token in the link (`?token=…`), checked by `assertCanAccessAppointment` against `GuestAppointmentAccessToken` (see `src/services/scheduling/guest-access.service.ts` and `docs/notifications.md`). The appointment id alone is rejected; the page returns "not found" and the actions refuse. This is covered by `src/services/scheduling/__tests__/guest-access.integration.test.ts` ("an appointment id alone is rejected"). What was originally described here — no magic-link/OTP flow in Phase 4 — is still true of the phase as shipped.
## 8. Security / ownership rules
@@ -117,11 +117,11 @@ Found and fixed while verifying, not left in place:
- **A silent test-cleanup leak** in the Phase 3 integration fixtures: `cleanupTestBusiness` tried to delete the test `Business` first, but `Appointment.staff` is `onDelete: Restrict` (a deliberate production safeguard), which blocked the cascade to `StaffProfile` for any test that had actually booked something — the failure was swallowed by a bare `.catch(() => {})`. This had been silently leaking a "Test Barbershop" row per affected test run since Phase 3; it only surfaced now because a manual verification pass queried `getPrimaryBusiness()` against a database that had just run the integration suite and picked up a leaked row instead of the real seed. Fixed by deleting the business's appointments before the business itself.
- **Desktop layout was effectively unstyled for the "browsing" screens** (Home, Appointments, Settings, and the bottom nav) — content simply didn't reflow or cap its width above mobile size, leaving a large empty right-hand column rather than anything intentional. Applied a **baseline** fix (a centered `max-w-2xl` content column, matching the pattern the booking flow's individual steps already used) across Home, Appointments, Settings, the shared `BottomNav`, `BarberDateTimePicker`, and `DetailsForm`. **This is not a full desktop redesign** — no side-by-side / grid layouts were built for browsing screens, only a consistent centered treatment so nothing looks broken. A genuine desktop-specific layout (e.g. a wider services grid, a split-pane appointment list) is a reasonable Phase 5+ item if desktop usage turns out to matter.
- **Reschedule's route was moved out of the `(customer)` route group** (`(customer)/appointments/[id]/reschedule` → top-level `appointments/[id]/reschedule`) after discovering the shared picker's own fixed bottom action bar was being covered by the persistent bottom nav on that route, blocking the "Confirm Reschedule" button. Matches the same reasoning `/book`'s layout already documents.
-- **Guest appointment access remains order-tracking-by-id only** (§7, point 5) — not a bug, but worth restating as the clearest remaining security-adjacent scope boundary: a guest with the appointment id can view/reschedule/cancel it without further verification. Fine for this phase's threat model (ids are unguessable `cuid`s, never enumerable), but a real magic-link/OTP flow would close this gap properly.
+- ~~**Guest appointment access remains order-tracking-by-id only**~~ — *closed in Phase 7.* A guest with only the appointment id can no longer view, reschedule, cancel or export the booking; they need the token from their confirmation page or email link (§7, point 5). Still open and deliberate: the token is a bearer link (anyone holding it has access until it expires, 90 days after the appointment ends) rather than a verified identity such as an OTP.
## 13. Before Phase 5
-- Decide whether guest-booking access needs stronger verification (magic link / OTP) before this becomes a compliance concern, or whether "unguessable id" remains acceptable for the foreseeable future.
+- ~~Decide whether guest-booking access needs stronger verification~~ — answered in Phase 7 with per-appointment access tokens; a magic-link/OTP step on top remains optional.
- If desktop usage is expected to matter, budget real design time for Home/Appointments rather than the baseline centering applied here.
- The `e2e/` suite and `test:integration` script both assume a disposable Postgres the caller provides — there's no CI wiring yet to spin one up automatically; that's worth doing before this becomes the main safety net.
- Loyalty UI, staff/owner-facing screens, notifications, and payments are all still untouched, exactly as scoped.
diff --git a/docs/phase-6-summary.md b/docs/phase-6-summary.md
index 8320019..97c3c20 100644
--- a/docs/phase-6-summary.md
+++ b/docs/phase-6-summary.md
@@ -158,7 +158,7 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 19. Known limitations
- **Working-hours editing has no conflict detection**, on both the self-service (Phase 5) and owner-on-behalf-of-staff (this phase) editors — unlike breaks, time off, business hours, staff deactivation, and service-eligibility removal, all of which do. A pre-existing Phase 5 gap this phase chose not to retroactively fix, given the size of the remaining Phase 6 scope; flagged again here rather than silently carried forward.
-- **Owner-editable breaks/whole-day time off for a staff member were not built** — only partial-day "Block Time" (via `OwnerBlockTimeButton`) satisfies "add time off on behalf of staff" this phase; a staff member's own recurring breaks and whole-day time off can currently only be edited by that staff member themselves.
+- **~~Owner-editable breaks/whole-day time off for a staff member were not built~~** — *resolved.* Owner-on-behalf-of editing of a staff member's breaks and whole-day time off was built in Phase 7 (Manage → Team → staff → Breaks / Time Off). The one remaining piece, editing and deleting partial-day "Block Time" entries (which `OwnerBlockTimeButton` creates), now also works: they are listed on the same Time Off page, for the owner (`MANAGE_STAFF`) and for staff on their own Time Off settings page, using the same update/delete actions and conflict review.
- **Staff service eligibility is edited only from the Staff Detail page**, not duplicated on the Services & Categories page (the brief mentions "assign eligible staff" under Services too) — one edit surface, avoiding two places that could drift out of sync.
- **"Requires staff approval to redeem" is informational text only** in the loyalty config UI — redemption is already gated behind the `MANAGE_LOYALTY_STAMPS` permission and there is no customer-initiated redemption request to approve against, so no separate approval-queue workflow exists.
- **A repeated run of the E2E suite against the same (non-reseeded) database is not fully idempotent**: journey 10 (redeem reward) consumes the seed's one AVAILABLE reward, and one seed appointment (`appt-today-checkedin`, timed via `minutesFromNow` to reliably exercise `selectCurrentAppointment`'s CHECKED_IN tier) can occasionally start a few minutes before the business's 10:00 opening depending on exactly when the seed happens to run, which the opening-hours conflict test now handles defensively rather than assuming a clean save — this is a real, if narrow, characteristic of time-relative seed fixtures re-run without a fresh database, same category of issue Phase 5's own summary flagged (§13 there); it does **not** affect a single authoritative run against a freshly seeded database, which is how CI should always run it.
@@ -166,7 +166,7 @@ npm run test:e2e # needs DATABASE_URL → a real (throwaway) Postgres
## 20. Review items for Phase 7
-- Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built, or whether "Block Time" (partial-day) remains the only owner-side lever indefinitely.
+- ~~Decide whether owner-editable breaks and whole-day time off (on behalf of staff) should be built~~ — built in Phase 7; Block Time edit/delete followed later (see §19).
- Decide whether working-hours edits (both self-service and owner-on-behalf-of) should gain the same conflict-review treatment as breaks/time-off/business-hours, closing the gap flagged in §19.
- Real notification delivery (email/SMS/push) is still entirely unbuilt — both the Phase 5 preference grid and this phase's invite-link workaround are waiting on it.
- Payments/deposits and any real financial reporting beyond the operational dashboard estimate are unstarted; `Appointment.discountAmount` exists and is populated correctly, but nothing yet reconciles it against an actual payment.
diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts
index e76acb1..d6330ee 100644
--- a/e2e/customer-journeys.spec.ts
+++ b/e2e/customer-journeys.spec.ts
@@ -66,25 +66,33 @@ test.describe("customer booking journeys", () => {
await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible();
});
- test("3. customer views upcoming appointments", async ({ page }) => {
+ test("3. customer views all appointments in one list", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email);
await page.goto("/appointments");
- // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed.
- await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
- const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name);
+ // One list, no tabs.
+ await expect(page.getByRole("tab")).toHaveCount(0);
+
+ // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed — reschedulable and cancellable.
+ // The seed also has a completed Haircut & Style with Marcus Reed, so pick the card by status too.
+ const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name).filter({ hasText: "Confirmed" });
await expect(upcomingSeeded).toBeVisible();
await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible();
+ await expect(upcomingSeeded.getByRole("link", { name: "Reschedule" })).toBeVisible();
+ await expect(upcomingSeeded.getByRole("button", { name: "Cancel", exact: true })).toBeVisible();
+ await expect(upcomingSeeded.getByRole("button", { name: "Book Again" })).toHaveCount(0);
- // Seeded: a COMPLETED Haircut & Style with Leo Vance.
- await page.getByRole("tab", { name: "Past" }).click();
- const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
+ // Seeded: a COMPLETED Haircut & Style with Leo Vance — same list, Book Again instead.
+ const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
await expect(pastSeeded).toBeVisible();
await expect(pastSeeded.getByText("Completed")).toBeVisible();
+ await expect(pastSeeded.getByRole("button", { name: "Book Again" })).toBeVisible();
+ await expect(pastSeeded.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
- // Alex Chen has no cancelled appointments in the seed data.
- await page.getByRole("tab", { name: "Cancelled" }).click();
- await expect(page.getByText("No cancelled appointments")).toBeVisible();
+ // Newest first: the upcoming appointment is above the completed one.
+ const upcomingBox = await upcomingSeeded.boundingBox();
+ const pastBox = await pastSeeded.boundingBox();
+ expect(upcomingBox!.y).toBeLessThan(pastBox!.y);
});
test("4. customer reschedules an appointment", async ({ page }) => {
@@ -120,11 +128,11 @@ test.describe("customer booking journeys", () => {
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name });
await page.goto("/appointments");
- const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
+ const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
await expect(upcomingCard).toBeVisible();
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
- // Scoped to the currently-open — the Upcoming tab can have several
+ // Scoped to the currently-open — the list can have several
// cancellable cards, each with its own (closed) dialog in the DOM.
const dialog = page.locator("dialog[open]");
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 dialog.getByRole("button", { name: "Done" }).click();
- // Cancellation never deletes — it moves from Upcoming to the Cancelled tab.
- await expect(findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name)).toHaveCount(0);
- await page.getByRole("tab", { name: "Cancelled" }).click();
- const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
+ // Cancellation never deletes — the same card stays in the list, now with a Cancelled pill
+ // and Book Again instead of Reschedule/Cancel.
+ const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
await expect(cancelledCard).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 }) => {
@@ -147,8 +156,7 @@ test.describe("customer booking journeys", () => {
await page.goto("/appointments");
// 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);
+ const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
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.
@@ -161,8 +169,6 @@ test.describe("customer booking journeys", () => {
// A brand new appointment now exists alongside the historical one — the completed appointment is untouched.
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();
});
diff --git a/manager/.env.example b/manager/.env.example
index fc73aa7..44ab404 100644
--- a/manager/.env.example
+++ b/manager/.env.example
@@ -42,3 +42,19 @@ STACKS_DIR="/opt/nextchair/stacks"
# "Update" button (or `docker compose up -d --force-recreate`) to pick it
# up.
NEXTCHAIR_IMAGE="nextchair-app:latest"
+
+# ── Scheduler, backups, alerts (all optional) ───────────────────────────
+# The built-in scheduler (notification cron, daily Google reviews sync,
+# health checks, scheduled backups) starts with the Manager. Set to 1 to
+# turn it off, e.g. to keep using the host-crontab fallback
+# (stacks/infra/scripts/process-cron.sh) instead — never run both.
+# MANAGER_SCHEDULER_DISABLED="1"
+# Scheduled-backup times and the dashboard's dates use the container's
+# timezone (UTC by default). Set e.g. TZ="Europe/London" to change it.
+# TZ="Europe/London"
+# Largest .7z accepted by the Restore page's upload (bytes; default 4 GiB).
+# BACKUP_UPLOAD_MAX_BYTES="4294967296"
+# An external dead-man's-switch (healthchecks.io, Better Stack…): the
+# scheduler GETs this URL every 5 minutes, so you're told if the whole
+# VPS or the Manager is down — something a self-hosted check can't report.
+# MANAGER_HEARTBEAT_URL="https://hc-ping.com/your-uuid"
diff --git a/manager/Dockerfile b/manager/Dockerfile
index 9fccc86..9219e98 100644
--- a/manager/Dockerfile
+++ b/manager/Dockerfile
@@ -30,6 +30,11 @@ ENV NODE_ENV=production
# stacks/infra/compose.yaml's manager service (docker.sock).
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
COPY --from=builder --chown=manager:nodejs /app/node_modules ./node_modules
diff --git a/manager/README.md b/manager/README.md
index d8a288c..b1e71db 100644
--- a/manager/README.md
+++ b/manager/README.md
@@ -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
`src/lib/provisioning.ts` for the exact sequence.
- Per business: start / stop / restart / update (recreate the container
- against the current shared image tag — not a source rebuild) / view
- recent logs.
+ against the current shared image tag — not a source rebuild, and run as
+ a background job with live progress and an optional "back up first") /
+ view recent logs.
- Does **not** duplicate the `/install` wizard — after provisioning, the
operator is linked to `https:///install` to finish that
business's own setup.
+## Scheduler, jobs, backups and alerts
+
+All of this runs inside the Manager process — no host crontab, no extra
+service. State lives next to `global-settings.json`, under
+`STACKS_DIR/manager/` (and `STACKS_DIR/backups/`), so it survives rebuilds.
+
+- **Scheduler** (`src/instrumentation.ts` → `src/lib/scheduler*.ts`): one
+ guarded interval, a task registry with per-task intervals and
+ per-business overlap guards, last-run results in `scheduler-state.json`
+ (shown at Settings → Monitoring & scheduler). Tasks: notification cron
+ (`POST /api/cron/process-notifications` with each stack's `CRON_SECRET`,
+ every 30 s by default), daily Google reviews sync, health checks (every
+ 3 min by default), scheduled backups, hourly housekeeping, and an
+ optional external heartbeat (`MANAGER_HEARTBEAT_URL`). Turn it off with
+ `MANAGER_SCHEDULER_DISABLED=1`.
+ **Fallback:** `stacks/infra/scripts/process-cron.sh` (host crontab) still
+ works and does the same notification/reviews calls — use it only if the
+ scheduler is disabled, never both.
+- **Jobs** (`src/lib/jobs.ts`, `locks.ts`): Update, Backup, Restore and
+ Delete run as background jobs writing `manager/jobs/.json` (status,
+ steps, log tail); the UI polls `GET /api/jobs/`. Each holds a
+ per-business lock (`manager/locks/.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//`. 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
A single dedicated platform-operator credential (`MANAGER_EMAIL` /
diff --git a/manager/src/__tests__/proxy.test.ts b/manager/src/__tests__/proxy.test.ts
index 2d5fdf0..ef37459 100644
--- a/manager/src/__tests__/proxy.test.ts
+++ b/manager/src/__tests__/proxy.test.ts
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
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
@@ -64,3 +64,34 @@ describe("proxy — Manager auth gate", () => {
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);
+ });
+});
diff --git a/manager/src/app/api/__tests__/routes.test.ts b/manager/src/app/api/__tests__/routes.test.ts
new file mode 100644
index 0000000..76c1dd0
--- /dev/null
+++ b/manager/src/app/api/__tests__/routes.test.ts
@@ -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 = {}) {
+ 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/.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);
+ });
+ });
+});
diff --git a/manager/src/app/api/backups/[slug]/[file]/route.ts b/manager/src/app/api/backups/[slug]/[file]/route.ts
new file mode 100644
index 0000000..fb38291
--- /dev/null
+++ b/manager/src/app/api/backups/[slug]/[file]/route.ts
@@ -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;
+ return new Response(body, {
+ headers: {
+ "Content-Type": "application/x-7z-compressed",
+ "Content-Length": String(size),
+ "Content-Disposition": `attachment; filename="${file}"`,
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/manager/src/app/api/backups/upload/route.ts b/manager/src/app/api/backups/upload/route.ts
new file mode 100644
index 0000000..cc02f3d
--- /dev/null
+++ b/manager/src/app/api/backups/upload/route.ts
@@ -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 });
+}
diff --git a/manager/src/app/api/jobs/[id]/route.ts b/manager/src/app/api/jobs/[id]/route.ts
new file mode 100644
index 0000000..c2f024b
--- /dev/null
+++ b/manager/src/app/api/jobs/[id]/route.ts
@@ -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" } });
+}
diff --git a/manager/src/app/dashboard/[slug]/actions.ts b/manager/src/app/dashboard/[slug]/actions.ts
index bd59c7d..390dae4 100644
--- a/manager/src/app/dashboard/[slug]/actions.ts
+++ b/manager/src/app/dashboard/[slug]/actions.ts
@@ -4,7 +4,12 @@ import path from "node:path";
import { revalidatePath } from "next/cache";
import { composeDown, composeLogs, composeRestart, composeUp } from "@/lib/docker";
import { upsertEnvValues } from "@/lib/env-file";
+import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
+import { setIntentionallyStopped } from "@/lib/health-watcher";
import { fetchInstallationStatus } from "@/lib/installation-status";
+import { NO_JOB, type JobStartState } from "@/lib/job-state";
+import { assertNotLocked, BusinessLockedError } from "@/lib/locks";
+import { startUpdateJob } from "@/lib/operations";
import { randomHex } from "@/lib/random";
import { isValidSlug, stackDir, stackExists } from "@/lib/stacks";
@@ -17,27 +22,47 @@ async function assertStack(slug: string): Promise {
export async function startStackAction(slug: string): Promise {
const dir = await assertStack(slug);
+ await assertNotLocked(slug);
await composeUp(dir);
+ await setIntentionallyStopped(slug, false);
revalidatePath(`/dashboard/${slug}`);
}
export async function stopStackAction(slug: string): Promise {
const dir = await assertStack(slug);
+ await assertNotLocked(slug);
+ // Mark it first so the health watcher doesn't alert on a stop the operator asked for.
+ await setIntentionallyStopped(slug, true);
await composeDown(dir);
revalidatePath(`/dashboard/${slug}`);
}
export async function restartStackAction(slug: string): Promise {
const dir = await assertStack(slug);
+ await assertNotLocked(slug);
await composeRestart(dir);
revalidatePath(`/dashboard/${slug}`);
}
-/** Recreates the container against whatever the shared image tag currently points to (--force-recreate) — not a source rebuild. See manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild step. */
-export async function updateStackAction(slug: string): Promise {
- const dir = await assertStack(slug);
- await composeUp(dir, { forceRecreate: true });
- revalidatePath(`/dashboard/${slug}`);
+/**
+ * Recreates the container against whatever the shared image tag currently
+ * points to (--force-recreate) — not a source rebuild. See
+ * manager/.env.example's NEXTCHAIR_IMAGE comment for the actual rebuild
+ * step. Runs as a background job (steps: [back up], recreate, wait for
+ * /api/health, done) the UI follows with ; the per-business
+ * lock stops it overlapping a backup, restore or delete.
+ */
+export async function updateStackAction(slug: string, backupFirst: boolean): Promise {
+ 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 {
diff --git a/manager/src/app/dashboard/[slug]/backups/actions.ts b/manager/src/app/dashboard/[slug]/backups/actions.ts
new file mode 100644
index 0000000..dc033ba
--- /dev/null
+++ b/manager/src/app/dashboard/[slug]/backups/actions.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 };
+ }
+}
diff --git a/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx b/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx
new file mode 100644
index 0000000..04c47d5
--- /dev/null
+++ b/manager/src/app/dashboard/[slug]/backups/backups-panel.tsx
@@ -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 = {
+ 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(null);
+ const [jobId, setJobId] = useState(activeJobId);
+ const [jobRunning, setJobRunning] = useState(activeJobId !== null);
+ const [shownPassphrase, setShownPassphrase] = useState(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 (
+
+ {passphraseToShow &&
}
+
+
+
+
+
+
Backups
+
+ Back up now
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {jobId && setJobRunning(false)} />}
+
+ {backups.length === 0 ? (
+ No backups yet.
+ ) : (
+
+ {backups.map((backup) => (
+
+
+
{backup.fileName}
+
+ {new Date(backup.createdAt).toLocaleString()} · {formatBytes(backup.sizeBytes)} · {KIND_LABEL[backup.kind]}
+
+
+
+ Download
+
+
+ Restore…
+
+ remove(backup.fileName)}
+ className="rounded-md border border-danger/30 px-3 py-1 text-xs font-medium text-danger disabled:opacity-60"
+ >
+ Delete
+
+
+ ))}
+
+ )}
+
+
+
+ Passphrase
+
+ {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."}
+
+ {hasPassphrase && (
+
+ Generate a new passphrase…
+
+ )}
+
+
+ );
+}
diff --git a/manager/src/app/dashboard/[slug]/backups/page.tsx b/manager/src/app/dashboard/[slug]/backups/page.tsx
new file mode 100644
index 0000000..975345a
--- /dev/null
+++ b/manager/src/app/dashboard/[slug]/backups/page.tsx
@@ -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 (
+
+
+
+ ← {stack.name ?? slug}
+
+
Backups
+
+ Encrypted 7z archives of the database, media files and configuration, stored on this server under{" "}
+ backups/{slug}/ . To restore one — onto this business or a fresh server — use{" "}
+
+ Restore
+
+ .
+
+
+
+
+
+ );
+}
diff --git a/manager/src/app/dashboard/[slug]/business-actions.tsx b/manager/src/app/dashboard/[slug]/business-actions.tsx
index e97cb5b..7eabef5 100644
--- a/manager/src/app/dashboard/[slug]/business-actions.tsx
+++ b/manager/src/app/dashboard/[slug]/business-actions.tsx
@@ -1,6 +1,8 @@
"use client";
import { useState, useTransition } from "react";
+import { JobProgress } from "../job-progress";
+import { PassphraseNotice } from "../passphrase-notice";
import {
fetchLogsAction,
restartStackAction,
@@ -9,10 +11,18 @@ import {
updateStackAction,
} from "./actions";
-export function BusinessActions({ slug }: { slug: string }) {
+export function BusinessActions({ slug, activeJobId }: { slug: string; activeJobId: string | null }) {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState(null);
const [logs, setLogs] = useState(null);
+ const [updateOpen, setUpdateOpen] = useState(false);
+ const [backupFirst, setBackupFirst] = useState(true);
+ const [jobId, setJobId] = useState(activeJobId);
+ const [jobRunning, setJobRunning] = useState(activeJobId !== null);
+ const [newPassphrase, setNewPassphrase] = useState(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) => {
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 = () => {
setError(null);
startTransition(async () => {
@@ -41,7 +70,7 @@ export function BusinessActions({ slug }: { slug: string }) {
run(startStackAction)}
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 }) {
run(stopStackAction)}
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 }) {
run(restartStackAction)}
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 }) {
run(updateStackAction)}
+ disabled={busy}
+ onClick={() => setUpdateOpen((open) => !open)}
+ aria-expanded={updateOpen}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium disabled:opacity-60"
title="Recreates the container against the current shared image tag — not a source rebuild."
>
@@ -81,11 +111,36 @@ export function BusinessActions({ slug }: { slug: string }) {
View logs
+
+ {updateOpen && !jobRunning && (
+
+
+ Recreates the container against the current shared image tag, then waits for it to answer{" "}
+ /api/health . This does not rebuild the image — build{" "}
+ nextchair-app first if the code changed.
+
+
+ setBackupFirst(e.target.checked)} />
+ Back up first (recommended)
+
+
+ {isPending ? "Starting…" : "Run update"}
+
+
+ )}
+
{error && (
{error}
)}
+ {newPassphrase && }
+ {jobId && setJobRunning(false)} />}
{logs !== null && (
{logs}
)}
diff --git a/manager/src/app/dashboard/[slug]/delete/actions.ts b/manager/src/app/dashboard/[slug]/delete/actions.ts
index 5a32ab3..a41b400 100644
--- a/manager/src/app/dashboard/[slug]/delete/actions.ts
+++ b/manager/src/app/dashboard/[slug]/delete/actions.ts
@@ -1,16 +1,25 @@
"use server";
-import { redirect } from "next/navigation";
-import { deleteBusiness } from "@/lib/provisioning";
+import { ensureBackupPassphrase, hasBackupPassphrase } from "@/lib/global-settings";
+import { BusinessLockedError } from "@/lib/locks";
+import { startDeleteJob } from "@/lib/operations";
export interface DeleteBusinessState {
error: string | null;
+ /** Set once the delete job has started — the form then follows it with . */
+ 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
* it — the operator only ever types a *confirmation* string, never the
* slug being acted on as a submittable field.
+ *
+ * Deleting is a background job holding the business lock (so it can't
+ * overlap an update, backup or restore). With "final backup" ticked the
+ * job takes one first and aborts WITHOUT deleting if that backup fails.
*/
export async function deleteBusinessAction(
slug: string,
@@ -19,17 +28,16 @@ export async function deleteBusinessAction(
): Promise {
const confirmation = String(formData.get("confirmation") ?? "");
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 {
- 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) {
- 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()}`);
}
diff --git a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx
index bcd1fca..40517b3 100644
--- a/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx
+++ b/manager/src/app/dashboard/[slug]/delete/delete-business-form.tsx
@@ -1,9 +1,12 @@
"use client";
+import Link from "next/link";
import { useActionState, useState } from "react";
+import { JobProgress } from "../../job-progress";
+import { PassphraseNotice } from "../../passphrase-notice";
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 }) {
const boundAction = deleteBusinessAction.bind(null, slug);
@@ -11,8 +14,27 @@ export function DeleteBusinessForm({ slug }: { slug: string }) {
const [confirmation, setConfirmation] = useState("");
const canDelete = confirmation === slug;
+ if (state.jobId) {
+ return (
+
+ {state.newPassphrase &&
}
+
+
+ Back to businesses
+
+
+
+ );
+ }
+
return (
+
+ {canExport && (
+
+ {/* A plain link, not next/link: the response is a file download, not a page. */}
+
+ Export client data (CSV)
+
+
+ Profile, appointments, loyalty and messages sent. Private staff notes are not included.
+
+
+ )}
diff --git a/src/app/(staff)/staff/settings/time-off/page.tsx b/src/app/(staff)/staff/settings/time-off/page.tsx
index 713e97b..d65cd01 100644
--- a/src/app/(staff)/staff/settings/time-off/page.tsx
+++ b/src/app/(staff)/staff/settings/time-off/page.tsx
@@ -18,15 +18,14 @@ export default async function StaffTimeOffPage() {
if (!staff) redirect("/home");
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) =>
- wholeDay(rows).map((t) => ({
+ rows.map((t) => ({
id: t.id,
startDate: toCalendarDate(t.startDate),
endDate: toCalendarDate(t.endDate),
reason: t.reason,
+ startMinutes: t.startMinutes,
+ endMinutes: t.endMinutes,
}));
return (
diff --git a/src/app/api/customers/[id]/export/route.ts b/src/app/api/customers/[id]/export/route.ts
new file mode 100644
index 0000000..703bf2d
--- /dev/null
+++ b/src/app/api/customers/[id]/export/route.ts
@@ -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",
+ },
+ });
+}
diff --git a/src/features/booking/actions/customer-appointments.ts b/src/features/booking/actions/customer-appointments.ts
index e020449..c00da8a 100644
--- a/src/features/booking/actions/customer-appointments.ts
+++ b/src/features/booking/actions/customer-appointments.ts
@@ -53,12 +53,14 @@ export async function cancelCustomerAppointmentAction(input: unknown): Promise
{
const staffBBreaks = await prisma.break.findMany({ where: { staffId: staffB } });
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();
+ });
+ });
});
diff --git a/src/features/owner/actions/time-off.ts b/src/features/owner/actions/time-off.ts
index 6101611..e8a5a2c 100644
--- a/src/features/owner/actions/time-off.ts
+++ b/src/features/owner/actions/time-off.ts
@@ -9,6 +9,9 @@ import { createTimeOff, deleteTimeOff, updateTimeOff } from "@/services/staff/st
import type { TimeOffInput } from "@/services/staff/staff.service";
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;
+
export interface TimeOffConflictItem {
id: 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. */
export async function ownerCreateTimeOffAction(
staffId: string,
- input: Pick,
+ input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise {
const { owner, target, error } = await requireStaffManagerFor(staffId);
@@ -91,7 +94,7 @@ export async function ownerCreateTimeOffAction(
export async function ownerUpdateTimeOffAction(
staffId: string,
timeOffId: string,
- input: Pick,
+ input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise {
const { owner, target, error } = await requireStaffManagerFor(staffId);
diff --git a/src/features/staff/actions/time-off.ts b/src/features/staff/actions/time-off.ts
index 450ef43..77483e3 100644
--- a/src/features/staff/actions/time-off.ts
+++ b/src/features/staff/actions/time-off.ts
@@ -5,6 +5,9 @@ import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { createTimeOff, deleteTimeOff, updateTimeOff } 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;
+
export interface TimeOffConflictItem {
id: 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(
- input: Pick,
+ input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise {
const staff = await getActingStaff();
@@ -49,7 +52,7 @@ export async function createTimeOffAction(
export async function updateTimeOffAction(
timeOffId: string,
- input: Pick,
+ input: TimeOffActionInput,
acknowledgeConflicts?: boolean,
): Promise {
const staff = await getActingStaff();
diff --git a/src/features/staff/components/time-off-form-sheet.tsx b/src/features/staff/components/time-off-form-sheet.tsx
index 9878ed6..66e800c 100644
--- a/src/features/staff/components/time-off-form-sheet.tsx
+++ b/src/features/staff/components/time-off-form-sheet.tsx
@@ -15,17 +15,38 @@ export interface EditableTimeOff {
startDate: string;
endDate: string;
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;
+
+export function isPartialDay(t: Pick): 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. */
export interface TimeOffActionSet {
create: (
- input: Pick,
+ input: TimeOffSheetInput,
acknowledgeConflicts?: boolean,
) => Promise;
update: (
timeOffId: string,
- input: Pick,
+ input: TimeOffSheetInput,
acknowledgeConflicts?: boolean,
) => Promise;
remove: (timeOffId: string) => Promise;
@@ -58,6 +79,9 @@ export function TimeOffFormSheet({
const [startDate, setStartDate] = useState(existing?.startDate ?? todayIso());
const [endDate, setEndDate] = useState(existing?.endDate ?? existing?.startDate ?? todayIso());
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(null);
const [conflicts, setConflicts] = useState(null);
const [isPending, startTransition] = useTransition();
@@ -67,6 +91,8 @@ export function TimeOffFormSheet({
setStartDate(existing?.startDate ?? todayIso());
setEndDate(existing?.endDate ?? existing?.startDate ?? todayIso());
setReason(existing?.reason ?? "");
+ setStartTime(partial ? minutesToHhmm(existing!.startMinutes!) : "12:00");
+ setEndTime(partial ? minutesToHhmm(existing!.endMinutes!) : "13:00");
setError(null);
setConflicts(null);
};
@@ -77,13 +103,27 @@ export function TimeOffFormSheet({
};
const submit = (acknowledgeConflicts: boolean) => {
- if (startDate > endDate) {
+ let minutes: Pick = {};
+ 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.");
return;
}
setError(null);
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
? await actions.update(existing.id, input, acknowledgeConflicts)
: await actions.create(input, acknowledgeConflicts);
@@ -102,7 +142,7 @@ export function TimeOffFormSheet({
startDeleteTransition(async () => {
const result = await actions.remove(existing.id);
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;
}
handleClose();
@@ -111,7 +151,7 @@ export function TimeOffFormSheet({
};
return (
-
+
{conflicts && conflicts.length > 0 ? (
@@ -146,13 +186,23 @@ export function TimeOffFormSheet({
) : (
<>
-
- setStartDate(e.target.value)} />
- setEndDate(e.target.value)} />
-
+ {partial ? (
+ <>
+
setStartDate(e.target.value)} />
+
+ setStartTime(e.target.value)} />
+ setEndTime(e.target.value)} />
+
+ >
+ ) : (
+
+ setStartDate(e.target.value)} />
+ setEndDate(e.target.value)} />
+
+ )}
setReason(e.target.value)}
/>
@@ -166,7 +216,7 @@ export function TimeOffFormSheet({
{existing && (
- Remove Time Off
+ {partial ? "Remove Block" : "Remove Time Off"}
)}
>
diff --git a/src/features/staff/components/time-off-list.tsx b/src/features/staff/components/time-off-list.tsx
index 03ed736..ab9e02b 100644
--- a/src/features/staff/components/time-off-list.tsx
+++ b/src/features/staff/components/time-off-list.tsx
@@ -4,7 +4,7 @@ import { Plus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
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 {
const start = new Date(`${startDate}T00:00:00`);
@@ -15,6 +15,11 @@ function formatRange(startDate: string, endDate: string): string {
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({
upcoming,
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"
>
-
{formatRange(t.startDate, t.endDate)}
+
{formatEntry(t)}
{t.reason &&
{t.reason}
}
@@ -77,7 +82,7 @@ export function TimeOffList({