Skip to main content

webscheduler-notifications

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".

来源信息

仓库
niloc95/xscheduler_ci4
最近来源活动
2026年7月4日 18:23
检测到的 SKILL.md 语言
英语
星标
1
Forks
0

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
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 查看
这个 SKILL.md 很大,SkillsMP 这里只预览前一段内容。 在 GitHub 查看