Fixes
CI / ci (push) Canceled after 0s

Guest merge

Guest history now joins the account when the email is proven, in three places: OTP sign-in, Google sign-in, and staff adding a client. Unverified emails are never matched.

What moves: appointments, notes, push subscriptions and notifications go onto the account's profile, and the guest profile is deleted.
One email, several guests: if the email has several guest profiles (one per phone number), all are merged.
No profile yet: the oldest guest profile becomes the account's profile.
Account wins: it keeps its own name and phone, and only gaps get filled.
Stamps: summed, with the ledger entries moved across. If the combined total reaches the threshold, the reward is earned and the customer is notified.
Safety: it runs in one transaction and is audit-logged. A failure never blocks sign-in; the next sign-in retries.

Reminders
Staff: one reminder, 2h before; the 24h one is gone. Push and in-app are on by default and email is opt-in. Defaults are now per channel, and the settings page shows email off, push and in-app on.
Customer: one reminder at 24h. If booked too late for that, they get one at 2h instead, and none if the appointment is under 2h away.
In-app reminders: held back and released by the worker at the reminder time, so they no longer appear the moment you book. Cancelling or rescheduling clears them, and "mark all read" no longer marks unseen ones.
Guest links: every guest email now carries a working link. The booking-time token is unchanged, and later emails use one derived from the appointment id and AUTH_SECRET. Revoking a guest appointment's access kills both.
Reward expiry: two emails per reward at most, at 7 days and 1 day before.
Stale guard: the worker drops a reminder, without counting a failure, if the appointment has started or isn't confirmed.

OTP rate-limit tests (2): the failures came from my scratch environment, not the app. I had E2E_TEST_MODE=true in the scratch .env, which turns off the OTP rate limits, so the tests had nothing to assert on. I'd also used MariaDB, which throws error 1020 on concurrent upserts, while your real stack runs mysql:8.0. On MySQL 8 without the flag they pass every time. I also pinned that flag off in the integration config, so a .env with it set can't break them again. I tested that by turning it on in .env, and the OTP tests still passed.
Authorization and nav-role suites (2): this was a real bug. Both files import @/lib/auth/permissions, which builds the database connection when it's imported, before the test fixtures load .env. The suites crashed before running a single test, so the owner-permission checks (barbers can't do owner actions, nothing leaks across businesses) hadn't been running at all. I now load dotenv once in the integration config, so no test depends on import order. They run and pass (13 tests).
This commit is contained in:
Stefan Lazic committed 2026-10-07 16:39:12 +01:00
1 parent 7338c9d9e2
commit 1442d33d55
25 files changed
+1246 -302

No files matched your search

+49 -14
View File
@@ -47,7 +47,7 @@ no-email types" below for a separate, smaller mechanism that trims one
channel off a handful of these without making them toggleable).
- **Customer-facing**: only `BOOKING_REMINDER` is optional (and only
controls the 24h reminder — see "Reminder timing"). Everything else
controls the customer reminder — see "Reminder timing"). Everything else
(confirmations, changes, cancellations, loyalty) always notifies the
customer on every resolvable channel — not finding out your
appointment was cancelled is a real risk, not just noise.
@@ -75,6 +75,7 @@ preference, that stored value always wins, in either direction — this
function only governs the "nobody has touched this yet" fallback.
Most staff/owner types default to **on**. These default to **off**
(on every channel)
instead — a solo owner-barber otherwise gets emailed/pushed/in-app'd for
everything the moment they sign in, with no gentle on-ramp:
@@ -82,10 +83,16 @@ everything the moment they sign in, with no gentle on-ramp:
|---|---|
| `STAFF_NEW_APPOINTMENT` | Noisy for a busy barber; `STAFF_APPOINTMENT_CANCELLED` (which stays on) is the one that actually needs acting on. |
| `STAFF_APPOINTMENT_RESCHEDULED` | Same reasoning. |
| `STAFF_APPOINTMENT_REMINDER` | A same-day heads-up is nice-to-have, not essential, for the person actually doing the cutting. |
| `OWNER_NEW_BOOKING` | Same as STAFF_NEW_APPOINTMENT, from the owner's side. |
| `OWNER_NO_SHOW` | The barber who marked it already knows — there's no staff-facing equivalent by design. |
Defaults can also differ **per channel**, not just per type
(`DEFAULT_DISABLED_CHANNELS` in `types.ts`). Currently one:
`STAFF_APPOINTMENT_REMINDER` is **on** for push and in-app but **off** for
email — a barber gets a nudge on their phone two hours out without a second
email for something that's about to happen anyway. Email is opt-in under
Settings → Notifications.
## Fixed no-email types
A few types are `EMAIL` never, always, and unconditionally — not a
@@ -144,17 +151,41 @@ all.
## Reminder timing
`src/services/scheduling/reminder-scheduling.ts` schedules reminders at
fixed hours-before-appointment offsets, independently for the customer
and the assigned staff member:
fixed offsets before the appointment, independently for the customer and
the assigned staff member:
- **Customer** (`BOOKING_REMINDER`): 24 hours before only.
- **Staff** (`STAFF_APPOINTMENT_REMINDER`): 24 hours and 2 hours before
(each its own send) — but see the defaults table above, this type
defaults to off, so nothing actually sends unless the staff member
opts in.
- **Customer** (`BOOKING_REMINDER`): one reminder, **24 hours** before. If
the booking was made too late for that (a same-day or next-morning
booking), they get one **2 hours** before instead — never both, and never
nothing, unless the appointment is under 2 hours away.
- **Staff** (`STAFF_APPOINTMENT_REMINDER`): one reminder, **2 hours**
before. No 24-hour one. Push and in-app on by default, email opt-in —
see "Defaults" above.
- **Owner**: no reminders.
An offset that's already in the past by the time the appointment is
booked/approved is silently skipped, never scheduled retroactively.
A reminder time that's already in the past when the booking is
made/approved is never scheduled retroactively.
**In-app reminders are held back until they're due.** Every scheduled
notification's in-app copy is created `SCHEDULED`, not `SENT`, so it stays
out of the notification bell and unread count until the background
processor releases it at the reminder time (`releaseDueInAppReminders` in
`reminder-worker.ts`). Cancelling or rescheduling cancels it along with the
email/push copies, so a stale reminder never appears.
**Stale-send guard.** Before sending (or releasing) a reminder, the worker
checks the appointment is still `CONFIRMED` and hasn't started; if not, the
delivery is marked `CANCELLED` (not `FAILED`, so it doesn't count toward the
owner's delivery-failure alert). This is a backstop for the processor being
down long enough that a "24 hours before" reminder would otherwise arrive
after the appointment.
**Guest links.** A guest has no account, so every customer-facing link in
their emails carries an access token (`customerAppointmentLink` in
`guest-access.service.ts`). The booking-time token is random and only its
hash is stored, so later emails use a token *derived* from the appointment
id and `AUTH_SECRET` instead; `validateGuestAccessToken` accepts either, and
revoking a guest appointment's access kills both.
---
@@ -169,10 +200,10 @@ booked/approved is silently skipped, never scheduled retroactively.
| `BOOKING_CANCELLED` | The appointment is cancelled, by anyone | "Booking cancelled" (carries a "This was a late cancellation." note when applicable — not a separate type) | Always |
| `BOOKING_BARBER_CHANGED` | An owner reassigns the appointment to a different staff member | "Your barber has changed" | Only if the customer originally picked a specific staff member — see "Fixed no-email types" |
| `BOOKING_COMPLETED` | The appointment is marked completed | "Thanks for visiting" | Never |
| `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment | "Upcoming appointment reminder" | Default on |
| `BOOKING_REMINDER` *(optional)* | 24 hours before a confirmed appointment (2 hours before if booked with less than 24 hours' notice) | "Upcoming appointment reminder" | Default on |
| `LOYALTY_STAMP_ADDED` | A loyalty stamp is added to their card | "You've earned a loyalty stamp" | Never |
| `LOYALTY_REWARD_EARNED` | Their stamp count reaches the reward threshold | "You've earned a reward" | Always |
| `LOYALTY_REWARD_EXPIRING` | A daily sweep finds their reward is nearing its expiry date (fires once per calendar day it's still found, not once total) | "Your reward is expiring soon" | Always |
| `LOYALTY_REWARD_EXPIRING` | A reward's expiry is within 7 days — sent once when it enters that window, and once more when it's down to its last day (two at most per reward) | "Your reward is expiring soon" | Always |
| `LOYALTY_REWARD_REDEEMED` | Their reward is applied to an appointment | "Reward applied" | Never |
## Staff-facing
@@ -182,7 +213,7 @@ booked/approved is silently skipped, never scheduled retroactively.
| `STAFF_NEW_APPOINTMENT` *(optional)* | A new appointment is booked with them | "New appointment" | Off |
| `STAFF_APPOINTMENT_RESCHEDULED` *(optional)* | One of their appointments is rescheduled | "Appointment rescheduled" | Off |
| `STAFF_APPOINTMENT_CANCELLED` *(optional)* | One of their appointments is cancelled | "Appointment cancelled" | On |
| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 24 hours and 2 hours before their appointment | "Upcoming appointment reminder" | Off |
| `STAFF_APPOINTMENT_REMINDER` *(optional)* | 2 hours before their appointment | "Upcoming appointment reminder" | In-app and push on, email off |
| `STAFF_SCHEDULE_CHANGED` *(optional)* | An **owner** edits their working hours/breaks/time-off on their behalf — never fires from the self-service editors, since notifying someone about their own edit would just be noise | "Your schedule was updated" | On |
| `STAFF_INVITATION_RECEIVED` *(optional)* | They're added as a new team member | "You've been invited to join `{business}`" | On |
| `STAFF_APPOINTMENT_CHECKED_IN` | Defined in the schema, has a recipient assigned — **but nothing in the codebase currently triggers it.** Preference-controlled for forward-compatibility, but has no toggle in the UI since there's nothing to preview. Left dormant on purpose | — | — |
@@ -254,3 +285,7 @@ Not queued, not preference-controlled, no in-app record.
(shared by customer/staff/owner), wired up per audience in
`src/app/(customer)/settings/notifications/page.tsx` and
`src/app/(staff)/staff/settings/notifications/page.tsx`.
- **Guest-to-account merge** (what happens to a guest's history when they
later sign up): `src/services/customer/guest-merge.service.ts`, called from
`authorizeCredentials` (OTP sign-in), `src/auth.ts`'s Google `signIn`
event, and `createCustomerByStaff`.