- name
- webscheduler-notifications
- description
- WebScheduler notifications contract — canonical services, queue-first delivery, dispatch architecture, event/template rules, queue record contract, internal recipient resolution, reminder offsets behavior (independent processing, schedule fingerprint, stale cancellation), template loading and placeholders, MailerService unified email transport, and business-ID scoping. Use whenever you're touching notifications, email/SMS/WhatsApp delivery, the notification queue, reminders, templates, MailerService, or anything in `app/Services/Notification*` or `app/Services/Mailer*`. Triggers on phrases like "notification", "email", "SMS", "WhatsApp", "queue", "reminder", "template", "placeholder", "dispatch", "cron", "MailerService", "NotificationQueue", "appointment_reminder", "idempotency", "schedule fingerprint", "opt-out", "SMTP", "Mailpit".
# WebScheduler — Notifications Contract
## 1. Canonical Services
- `AppointmentEventService`
- `NotificationQueueService`
- `NotificationQueueDispatcher`
- `NotificationPolicyService`
- `NotificationEmailService`
- `NotificationSmsService`
- `NotificationWhatsAppService`
- `AppointmentNotificationService`
## 2. Delivery Contract
**Queue first, then dispatch**, with idempotency and delivery logging preserved.
SMTP transport for notifications is owned by `MailerService` — see §7 below.
**Integration `is_active` toggle is live, not a placeholder.** The "Active" checkbox under
Settings → Notifications for each channel (`email_is_active`, `sms_is_active`,
`whatsapp_is_active`) writes `xs_business_integrations.is_active`, which is checked by
`NotificationQueueDispatcher::isIntegrationActive()`, `NotificationQueueService::enqueueDueReminders()`,
`AppointmentNotificationService::sendEventEmail()`, and `NotificationSmsService`/`NotificationWhatsAppService`
send paths. A channel only sends when **both** this integration toggle and the per-event rule
(`xs_business_notification_rules.is_enabled`) are on. The view previously labeled these checkboxes
"Phase 2/Phase 3 readiness" with copy implying dispatch wasn't wired yet — that text was stale
(left over from the pre-dispatch v96 settings rewrite) and was corrected in
`app/Views/settings/tabs/notifications.php` on 2026-06-10. Do not reintroduce "future phase" framing
for these toggles.
Business-ID scoping for UI-facing notification methods is owned by `current_business_id()` in `permissions_helper`. See `architecture` skill (§8) for full resolution priority and service usage pattern. **Do not use `NotificationCatalog::BUSINESS_ID_DEFAULT` directly in UI-facing service methods.**
## 3. Dispatch Architecture Notes
- Booking-time notifications are queued **synchronously inline** during the appointment mutation request, but **dispatch is deferred** to the queue worker / cron (`php spark notifications:dispatch-queue`). **Do not perform SMTP or channel delivery inline in the HTTP request path.**
- New booking flows must derive event type from `AppointmentStatus::notificationEvent()` instead of hardcoding confirmation events.
- `PATCH /api/appointments/{id}/status` fires notifications server-side for each transition.
- Frontend events such as `appointments-updated` do not drive notification delivery; notifications are triggered by **backend** mutation flows.
- Manual resend flows use `POST /api/appointments/{id}/notify`. Email and SMS resends send immediately, while WhatsApp resends are queued.
### 3.1 HTTP Request Path Rule
- `AppointmentBookingService::queueNotifications()` must **enqueue only**. It must not instantiate `NotificationQueueDispatcher` for immediate delivery.
- Inline dispatch during booking/reschedule/cancel requests is a correctness and performance hazard: it blocks the HTTP response on SMTP/channel transport availability and turns transient transport failures into user-facing latency.
- **Correct behavior if SMTP/transport is down:** mutation succeeds, notification rows remain queued/failed for retry, and the cron/dispatcher handles recovery later.
## 4. Event and Template Rules
- `appointment_pending` is the canonical event for pending bookings.
- `appointment_confirmed` is the canonical event for confirmed bookings and completed appointments.
- `appointment_reminder` is **cron-only** and is not triggered by a status transition.
- `booking.default_appointment_status` is a notification control point because it determines whether new bookings emit `pending` or `confirmed` events.
- Customer and internal templates are split by `recipient_class`, and internal fallback templates exist for migration-drift safety.
See `scheduling` skill for the status → event mapping table.
## 5. Queue Record Contract
- **Customer-facing rows** are written through `AppointmentEventService` and `NotificationQueueService::enqueueAppointmentEvent()`.
- **Internal provider/staff rows** are written through `AppointmentBookingService::enqueueInternalNotifications()` and `NotificationQueueService::enqueueInternalEvent()`.
- Queue rows must preserve `attempts`/`max_attempts` retry semantics, `run_after` scheduling, and row-level locking through `locked_at` and `lock_token`.
- `idempotency_key` is the deduplication key; `correlation_id` is the tracing key shared with delivery logs.
### Key Queue Fields
- `recipient_type`
- `recipient_user_id`
- `attempts`
- `max_attempts`
- `run_after`
- `locked_at`
- `lock_token`
- `last_error`
- `sent_at`
- `idempotency_key`
- `correlation_id`
- `reminder_offset_minutes` (reminder rows only — offset that triggered this row; used for stale-reminder cancellation)
- `schedule_fingerprint` (SHA1 of `start_at|end_at|updated_at`; dispatcher cancels reminder rows whose fingerprint no longer matches the live appointment)
## 6. Architecture Flow
1. Appointment mutation determines event via `AppointmentStatus::notificationEvent()`
2. Queue rows written to `xs_notification_queue`
3. Dispatcher sends by channel
4. Attempt outcomes written to `xs_notification_delivery_logs`
## 7. Unified Email Transport Contract (Owner Section)
All email transport rules live here. Other sections contain reference-only reminders.
### 7.1 Single Transport Layer
`MailerService` is the **sole permitted email transport layer**. No other class may instantiate or configure a CI4 `Email` driver for production sends. Every outgoing email — password reset, booking notifications, and future system emails — flows through `MailerService::send()`.
### 7.2 Transport Resolution Priority (Strict)
1. **Active DB integration (all environments):** An `xs_business_integrations` row where `channel = 'email'` and `is_active = 1` exists for the business. Config is decrypted from `encrypted_config`. **This takes unconditional priority.**
2. **`.env` dev fallback (development only):** When `ENVIRONMENT === 'development'` and no active integration row is found, `MailerService` uses `Config\Email` (populated from `.env`). If `.env` does not specify `protocol = smtp`, the service falls back to a hardcoded Mailpit address (`127.0.0.1:1025`) provided a `fromEmail` is configured.
3. **`null` — cannot send:** If neither source yields a valid config, `resolveTransportConfig()` returns `null` and `send()` returns `['ok' => false, ...]`.
### 7.3 From-Address Ownership
`MailerService` resolves the from address using this priority:
1. `$fromEmailOverride` (caller-supplied, when non-empty)
2. `$config['from_email']` (from the resolved transport config)
3. Failure — an empty from address causes `send()` to return `['ok' => false]`.
**Callers must never set a from address on the email driver directly.**
### 7.4 `send()` Response Contract
```php
['ok' => bool, 'error' => ?string, 'transport' => string, 'messageId' => ?string]
```
- `transport` values: `'smtp'` (DB integration), `'dev-fallback'` (`.env`), `'unknown'` (error before transport resolved)
- `messageId` is always `null` (reserved for future SMTP header extraction)
### 7.5 `canSend()` — Queue Gate Capability Check
`MailerService::canSend(int $businessId)` returns `true` only when the resolved config has a non-empty `host` AND a non-empty `from_email`.
`NotificationEmailService::canUseDevelopmentFallbackSmtp()` is a thin delegate to `MailerService::canSend()`. This method is the **queue gate**; queue files (`NotificationQueueDispatcher`, `NotificationQueueService`) must not be changed to reference `MailerService` directly without updating this note.
### 7.6 `testConnection()` Isolation
`NotificationEmailService::sendTestEmail()` **bypasses `MailerService` intentionally**. It constructs a CI4 Email driver from a caller-supplied config object to test SMTP credentials in real time. Do not route `sendTestEmail()` through `MailerService` — the bypass is load-bearing for the settings integration wizard.
### 7.7 Auth Email Channel
Password reset emails use `NotificationCatalog::BUSINESS_ID_DEFAULT` (value `1`) as `$businessId`. `Auth::sendResetEmail()` renders the view, then calls `MailerService::send()`. Auth has no knowledge of SMTP configuration. See `auth-rbac` skill.
### 7.8 HTML Rendering Chokepoint (notification emails)
Notification emails send as **HTML**. `NotificationEmailService::sendEmail()` is the single
chokepoint — both `NotificationQueueDispatcher::sendEmail()` and
`AppointmentNotificationService::sendEventEmail()` funnel through it. It wraps the rendered
body in the responsive shell (`app/Views/emails/notification.php`) via `EmailBodyRenderer` and
calls `MailerService::send(..., 'html', ..., $altText)` with a flattened plain-text alternative
for multipart deliverability.
`EmailBodyRenderer` handles two body shapes:
- **HTML fragment** (redesigned customer templates) → used as-is in the shell content area.
- **Plain text** (internal templates, legacy/admin-customised templates) → safety net: escape
`<`/`>`, autolink bare URLs into friendly anchors (Maps/Waze/Calendar/Manage labels), `nl2br`.
This guarantees a plain-text body can never render as collapsed text now that email is HTML.
Do not add a second email-transport wrap path; keep the HTML wrap in
`NotificationEmailService::sendEmail()`. SMS and WhatsApp never use the shell or `EmailBodyRenderer`.
## 8. Internal Recipient Contract
Internal notifications:
- `recipient_type = internal`
- `recipient_user_id` stores `xs_users.id`
- Final contact resolution happens at dispatch time
- Eligibility follows `notify_on_appointments` preference
- Internal recipient selection is determined by `UserModel::getNotifiableUsersForProvider()`
- Both admin user-management and self-service profile surfaces write the `notify_on_appointments` preference
## 9. Timezone and Delivery Prerequisites
- Notification rendering must resolve display timezone from `xs_appointments.stored_timezone` first.
- If `stored_timezone` is unavailable, fall back to `TimezoneService::businessTimezone()`.
- Do not rely on implicit session timezone when rendering queued notifications.
- Production delivery requires enabled business notification rules and active business integrations with non-empty encrypted configuration.
- If required rule or integration configuration is missing, queue rows can be cancelled or failed during dispatch.
Dev-only SMTP fallback for email: see §7.2 for transport priority and §7.5 for the queue gate. The fallback covers both queue-enqueue checks and queue-dispatch checks transparently.
See `database` skill for the full Timezone Integrity contract.
## 10. Reminder Offsets Behavior
### 10.1 Independent Offset Processing
**Each reminder offset triggers independently. No offset blocks another.**
Configuration (Settings → Notifications → Customer Reminder Offsets):
- Primary offset: e.g., `4320` minutes (3 days before)
- Secondary offset: e.g., `60` minutes (1 hour before)
- Offsets are stored as an array in `xs_business_notification_rules.reminder_offsets_json`
`NotificationQueueService::enqueueDueReminders()` processes each offset separately:
```
For each appointment where start_at in (now-48h, +30 days]:
For each channel (email, sms, whatsapp):
For each offset in that channel's offset list:
dueAt = start_at - offset_minutes
If now < dueAt → skip (not yet due)
If dueAt < created_at → skip (offset window elapsed BEFORE booking existed —
would just echo the confirmation; NOT a real reminder)
Else → enqueue a row with marker 'offset:N'
```
There is no cross-offset dependency. An offset that is already past-due does not affect a future offset that has not yet arrived.
**Booking-window guard (the `dueAt < created_at` check).** A reminder only fires when its
scheduled moment is at or after the appointment was booked. This distinguishes a genuine
cron-downtime **catch-up** (appointment booked earlier, reminder became due while cron was
stopped → `dueAt >= created_at` → still fires) from a **short-lead booking** whose offset window
had already passed before the appointment even existed (`dueAt < created_at` → suppressed).
Without this guard, booking inside the primary reminder window (e.g. an appointment 1 day out
with a 3-day offset) sends an "Upcoming Appointment" reminder seconds after the confirmation —
reported and fixed 2026-07-04. `created_at` is UTC (BaseModel `useTimestamps` + `app.appTimezone`),
compared against the UTC `dueAt`.
### 10.2 Concrete Example — Appointment Booked 1 Day in Advance
**Config:** `[4320 min (3 days), 60 min (1 hour)]`
**Booking time (`created_at`):** today 14:00 UTC. **Appointment:** tomorrow 14:00 UTC.
| Offset | dueAt | now >= dueAt | dueAt >= created_at | Result |
|--------|-------|-------------|---------------------|--------|
| 4320 min (3 days) | 2 days ago | ✅ TRUE | ❌ FALSE (window passed before booking) | **Suppressed** — would only echo the confirmation |
| 60 min (1 hour) | tomorrow 13:00 | ❌ FALSE | — | Skipped for now; enqueued when tomorrow 13:00 arrives |
Only the 1-hour reminder sends. The 3-day reminder is correctly suppressed because the
appointment was booked *inside* the 3-day window. (If the same appointment had instead been
booked 5 days ago and cron had been down, the 3-day offset's `dueAt` would be ≥ `created_at`
and it *would* fire as a legitimate catch-up.)
### 10.3 Queue Row Identity
Each offset creates its own queue row with:
- `reminder_offset_minutes` — the specific offset value that triggered this row
- `idempotency_key` — includes marker `'offset:N'` so the same offset for the same appointment is never double-enqueued
- `schedule_fingerprint` — SHA1(`start_at|end_at|updated_at`); dispatcher cancels a reminder row if the fingerprint no longer matches the live appointment (i.e., appointment was rescheduled after enqueue)
### 10.4 `reminder_sent` Flag — Compatibility Only
After a **customer** reminder dispatches successfully, `reminder_sent = 1` is set on the appointment row via `NotificationQueueDispatcher::markReminderSentIfSupported()`.
**This flag is NOT checked as an enqueue-time filter.** `enqueueDueReminders()` does not skip appointments with `reminder_sent = 1`. The flag is for **display purposes only**.
> **Known hazard (fixed 2026-04-23, commit `e5bf2e7`):** Previously, the flag write used `model->update(['reminder_sent' => 1])`, which also updated `updated_at`. That changed the schedule fingerprint mid-dispatch, causing sibling reminder rows for the same appointment to be cancelled as stale. The fix writes `reminder_sent` via query builder `set()` without touching `updated_at`.
### 10.5 Debugging Reminder Offsets
```bash
# Force an appointment into a reminder-due window, then enqueue + dispatch
php spark notifications:test-reminder <appointmentId> [businessId] [minutesUntilStart]
# Example: set appointment to start 45 min from now
php spark notifications:test-reminder 96 1 45
```
Output shows per-offset queue rows with their `reminder_offset_minutes`, `status` (`sent` / `queued` / `cancelled`), and `schedule_fingerprint`. A healthy run shows one row per offset per recipient, all with `status=sent` and `Cancelled: 0`.
## 11. Template Contract
### 11.1 Recipient Classes
- **`customer`** — outbound to the person who booked. Resolved via settings-based custom templates, then `DEFAULT_TEMPLATES` in code.
- **`internal`** — outbound to providers/staff. Resolved via `xs_message_templates` rows seeded by migration, then `DEFAULT_INTERNAL_TEMPLATES` in code.
### 11.2 Template Loading Priority — Customer Class
1. `xs_settings` row with key `notification_template.{event_type}.{channel}` (JSON value `{"subject":"...","body":"..."}`)
2. `NotificationTemplateService::DEFAULT_TEMPLATES` (code-level fallback)
Settings-based templates are upserted by migrations and can be overridden at runtime without code deploys.
### 11.3 Template Loading Priority — Internal Class
1. `xs_message_templates` row where `recipient_class = 'internal'`, `is_active = 1`
2. `NotificationTemplateService::DEFAULT_INTERNAL_TEMPLATES` (code-level fallback)
### 11.4 Supported Placeholder Set (37 total)
**Customer info:** `{customer_name}`, `{customer_first_name}`, `{customer_email}`, `{customer_phone}`
عرض على GitHub