# Notify (Shared · `Src\Common\Notify`)

**Context:** Shared library + one Manage page · **Routes / UI:** `manage.integrations.notifications.*` · **Entry point:** [`Notifier`](/src/Common/Notify/Services/Notifier.php) · **Transport today:** Telegram

## What it does

Pushes a short operational message to a human's **phone** — "a customer just
WhatsApped us", "the bridge is down" — and lets each admin decide, in the app,
**where** they want to be reached and **which** events should reach them.

It is the notification counterpart of [`AiClient`](/docs/modules_handbook/shared/ai/readMe.md):
one provider-agnostic entry point that every feature calls, with the provider
hidden behind a driver. A feature fires **one line** and names only an *event* —
never a person, never a channel:

```php
app(Notifier::class)->send('whatsapp.message_received',
    NotifyMessage::make('New WhatsApp message', $text)->subject($message));
```

Who receives that is the admins' business (the subscription matrix on
**Manage → Notifications**), and how it is delivered is the transport's. That
separation is the whole design: adding a recipient, muting an event, or later
adding WhatsApp delivery never touches the code that fires the notification.

> **This is for alerting a colleague, not messaging a customer.** Customer
> messaging goes through [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md)
> (with its consent ladder + ban-risk guards) or [SMS](/docs/modules_handbook/shared/sms/readMe.md).
> Telegram is used here precisely because it is free, unmetered and carries no
> ban risk, so alerting can be as chatty as it needs to be.

## How it works

- **Three tables, three questions.**
  - **`notify_destinations` — WHERE.** One row per reachable chat, of two
    `kind`s: **PERSONAL** (an admin's own Telegram chat, `user_id` set — only
    they see and receive it) and **SHARED** (a group everyone is in, `user_id`
    null). Adding a colleague to a shared group is "pull them into the group" —
    no code, no config, no row.
  - **`notify_subscriptions` — WHAT.** One row per `(event_key, destination)`,
    enforced by a unique index. The pairing belongs to the **destination, not
    the person who ticked it**: three admins all wanting an alert in the team
    group produce ONE row, so the group is notified once, not three times.
  - **`notify_deliveries` — WHAT HAPPENED.** One row per attempt (the
    `ai_requests` of notifications). Opened `PENDING`, settled `SENT`/`FAILED`.
    Crucially it also records the deliberate **`SKIPPED`** non-sends —
    *throttled*, *nobody is subscribed*, *no bot token* — which are otherwise
    completely invisible and are the usual answer to "why didn't my phone buzz?".
    The destination label + address are **snapshotted** so history stays
    readable after a destination is deleted.
- **Credentials are `.env`-only.** `TELEGRAM_BOT_TOKEN` is a full send
  credential and lives in `.env`, exactly like the WhatsApp Cloud credentials
  ([settings.md](/docs/modules_handbook/manage/messages/whatsapp/settings.md)) and the
  SMS360 gateway. The DB holds only admin-tunable routing. The page shows
  whether the token works; it never stores or displays one.
- **The event registry is the authority.** Every notification runs under an
  event key registered in [config/notify.php](/config/notify.php) (`name`,
  `description`, `group`, `default`, `throttle`, per-event `options`). An
  unregistered key is **refused and logged**, never delivered — the same "a typo
  can never silently work" rule as the AI prompt registry. Adding an event is a
  config entry plus one `send()` call; the checkbox appears on the page by
  itself.
  > ⚠️ **Event keys contain dots** (`whatsapp.message_received`), so they are
  > literal array keys that Laravel's dot notation **cannot address** —
  > `config('notify.events.whatsapp.message_received')` is `null`, and writing
  > to that path silently creates a bogus `events['whatsapp']` sibling. Always
  > read through [`NotifyEvent`](/src/Common/Notify/NotifyEvent.php) (it indexes
  > the array in PHP), and to override an event in a test replace the whole
  > `notify.events` array. `NotifyEvent::all()` additionally filters out
  > anything that isn't a well-formed entry, so a stray nested write can never
  > masquerade as a real subscribable event.
- **Dispatch is queue-only.** `Notifier::send()` resolves subscriptions and
  queues one [`SendNotificationJob`](/app/Jobs/Notify/SendNotificationJob.php)
  per destination — the HTTP call to Telegram happens on a worker, so a slow or
  unreachable provider can never hold up the webhook that fired it, and one
  blocked recipient can't stop the others receiving it. The job payload is plain
  data (`NotifyMessage::toArray()`), **never a model**, so nothing in it can
  fail to unserialize on the worker.
- **Retries are decided by the transport, not guessed.** Every transport returns
  a [`NotifyResult`](/src/Common/Notify/NotifyResult.php) carrying `retryable`.
  A timeout / 5xx / **429** is released with backoff (honouring Telegram's own
  `retry_after`) until `notify.queue.retry_until_minutes`; a **permanent**
  failure — bad token (401), blocked bot or kicked from the group (403), unknown
  chat (400) — **stops on the first attempt**, because retrying it only burns
  queue capacity to fail identically.
- **Throttling is per scope, atomically.** An event's `throttle` window is
  claimed with `Cache::add` (atomic, so it is correct across concurrent
  workers), keyed by the message's `throttleScope`. WhatsApp scopes it **per
  conversation**, so a customer firing off five messages buzzes the phone once —
  while a *different* customer still gets through. A cache outage **fails open**
  (send anyway): losing the mute is far better than losing the alert.
- **It can never break its caller.** A notification is a side effect.
  `Notifier::send()` never throws (outermost try/catch), transports are
  contractually forbidden from throwing (they return a failed result), and the
  delivery log is best-effort — a logging failure never breaks the send.
- **Ownership is enforced server-side.** An admin manages their own personal
  chats plus every shared group; a colleague's personal chat **404s** (not 403 —
  whether another admin registered one is not this admin's business). A personal
  destination is always stamped with the acting admin, never a `user_id` from
  the request. Viewing the page needs `view-integrations`; every mutation —
  **and chat discovery, which only serves the Add modal** — needs
  `manage-integrations`.

### Adding a transport (WhatsApp is the planned second)

[`NotifyManager`](/src/Common/Notify/Services/NotifyManager.php) is the **only**
place that knows which transports exist — the direct twin of
[`WhatsappManager::driver()`](/src/Whatsapp/Services/WhatsappManager.php).
Adding one is:

1. a class implementing [`NotifyTransport`](/src/Common/Notify/Contracts/NotifyTransport.php)
   (`send(string $address, NotifyMessage $message): NotifyResult` + `isConfigured()`);
2. a `TRANSPORT_*` constant + `TRANSPORTS` entry on `NotifyDestination`;
3. one `match` arm in `NotifyManager::driver()`.

`Notifier`, the job, the controller, the page and **every calling feature** stay
untouched. A WhatsApp transport would wrap the existing `WhatsappDriver`
(`sendText` to an admin's number or a group JID) and render `NotifyMessage` as
plain text instead of HTML — which is exactly why `NotifyMessage` carries no
markup of its own.

## Reference usage — the WhatsApp inbound alert (the canonical consumer)

The shipped consumer is
[`ProcessInboundWhatsAppWebhook::considerNotify()`](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php).
Copy this shape:

```php
use Src\Common\Notify\NotifyEvent;
use Src\Common\Notify\NotifyMessage;
use Src\Common\Notify\Services\Notifier;

app(Notifier::class)->send('whatsapp.message_received', NotifyMessage::make(
    'New WhatsApp message',                 // bold first line
    $excerpt                                // the body
)
    ->line('From', $senderName)             // blank values are dropped
    ->line('Number', $contact?->phone_e164)
    ->url(route('manage.messages.inbox', ['conversation' => $conversation->uuid]), 'Open in Inbox')
    ->subject($message)                     // → the delivery row's morph
    ->throttleScope('conversation:' . $conversation->id));
```

### Addressed notifications — `sendToUsers()` (assignment-style events)

Some events are inherently **addressed**: "this action item was assigned to
YOU" has a correct audience of exactly the assigned admins, and a shared group
or an uninvolved colleague's phone is the wrong place for it. For those, and
only those, `Notifier::sendToUsers($eventKey, $message, $userIds)` fans out
like `send()` but keeps only the given users' **PERSONAL** destinations —
shared groups are always excluded. Everything else is unchanged: the event must
be registered, the destination must hold an **active subscription** to it (each
admin still controls the alert from the matrix — no personal destination or an
unticked event means a logged `no_subscribers` skip, never an error), throttle
and config gates apply, and delivery is queued. The caller still never names a
channel. Four shipped consumers — two folded into the list below, two more (each needing its
own note) as separate call-outs after it:

- `leads.action_item_assigned`
  ([ActionItemsController::notifyAssignees()](/app/Http/Controllers/Manage/Leads/ActionItemsController.php)
  — fired for **newly added** assignees only, never the acting admin themself; and
  only holders of a **manage role** are assignable in the first place, so a
  role-less account never reaches the alert). Pinned by
  [`tests/Feature/Leads/ActionItemNotifyTest.php`](/tests/Feature/Leads/ActionItemNotifyTest.php):
  every rule on this path fails **silently** — the colleague simply never hears
  about their task — so the addressed fan-out, the actor exclusion, the
  shared-group exclusion, the opt-in gate and the no-re-buzz-on-edit rule each
  carry an assertion rather than a comment.
- `conversation.review_received`
  ([RecordingReviewsController::notifyOwner()](/app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php)
  — a human sales-performance review (closing-rating-and-review.md Phase 6)
  pushes the recording's OWNER (the agent being rated), never the reviewer, on
  **create only**. Two things worth calling out because they are NOT the
  obvious shape for an addressed event: the recipient is resolved through an
  extra hop (`reviewable.admin_id` → `Admin` row → `admin.user_id` — the
  target is never the acting admin's own id), and the message is deliberately
  **score-only, never the comment** — the Telegram transport sends
  `parse_mode => 'HTML'`, and a comment is free text an admin typed, so
  omitting it from the message entirely deletes that bug class rather than
  merely escaping it. The deep link it carries lands on the recording's own
  Sales performance tab, never Overview, since the push is otherwise the only
  pointer to where the (withheld) feedback lives.

A THIRD addressed consumer shipped 2026-08-13: **`events.vsl_caller_assigned`**
([`Concerns\NotifiesVslCaller`](/app/Http/Controllers/Concerns/NotifiesVslCaller.php),
fired from `Main\ConsultationController::considerCallerRotation()` once the claim
transaction has committed). A VSL funnel's caller ROTATION decides who owns a new
1-on-1, so unlike every other assignment on this system **no human typed that
person's name** — which makes this alert the only thing that tells them the lead
exists at all. Scoped `consultation:{id}`, so a busy funnel can never mute the
second person assigned in the same minute. Pinned by
[`tests/Feature/Event/VslCallerRotationTest.php`](/tests/Feature/Event/VslCallerRotationTest.php),
which asserts exactly ONE queued job: a `send()` here would fan a customer's
contact details into every shared group.

A FOURTH addressed consumer shipped 2026-08-27: **`calendar.activity_assigned`**
([`Concerns\NotifiesActivityAssignees`](/app/Http/Controllers/Concerns/NotifiesActivityAssignees.php),
fired from `Manage\Calendar\ActivitiesController` after the activity write commits — see the
[Calendar](/docs/modules_handbook/manage/calendar/readMe.md#notification--calendaractivity_assigned)
doc for the full assignment-model context). A calendar Activity's admin roster is
multi-valued and mutable, unlike the other three consumers' single fixed assignee, which drives
two things worth calling out: **create** notifies every assignee except the actor, while
**update** notifies only the assignees that write ADDED (diffed against the roster as it stood
before the write — someone already on it is never re-buzzed just because another field changed);
and the throttle is **`0`**, not a window, because `Notifier::dispatch()` claims its throttle key
once per `sendToUsers()` call, BEFORE resolving destinations — a per-activity `throttleScope`
protects a second, different activity, never a second assignment event on the SAME one, so a
non-zero window here silently ate the second of two assignments made in quick succession (the
ordinary case, not a rare one). Pinned by
[`tests/Feature/Calendar/ActivityAssignmentNotifyTest.php`](/tests/Feature/Calendar/ActivityAssignmentNotifyTest.php).

Four rules that example demonstrates:

1. **Name an event, not a person.** Never look up recipients at the call site.
   (`sendToUsers()` is the one sanctioned exception, for events whose audience
   IS the addressed person — and even there the admin's subscription still
   decides whether it lands.)
2. **Always set a `throttleScope`** when the event can repeat per entity —
   without one the whole event mutes globally, and one busy thread silences
   every other.
3. **Guard at the source, driven by the registry.** `considerNotify()` skips
   non-inbound messages, **history backfills** (a fresh QR link replays
   thousands of old messages — every one would otherwise buzz), group chats, and
   any provider not listed in the event's `options.providers` (Cloud API only by
   default). Those knobs live in config, not in the code.
4. **Set `subject()`** so the delivery row points back at what caused it.

Sending directly to one destination (the page's **Send test**) uses
`Notifier::deliver($destination, $message)` — synchronous, bypassing
subscriptions and the throttle, returning the real `NotifyResult` so the admin
sees the provider's own words ("Forbidden: bot was blocked by the user"), which
is what tells them how to fix it.

In tests, fake the HTTP layer rather than the service — see
[NotifyTest](/tests/Feature/Common/NotifyTest.php):

```php
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true, 'result' => ['message_id' => 1]], 200)]);
// …or assert the fan-out without sending at all:
Queue::fake();
```

## Setting it up

1. **Create the bot.** On Telegram, message **@BotFather** → `/newbot` → follow
   the prompts. It answers with a token.
2. **Put the token in `.env`** and restart the queue workers
   (`php artisan horizon:terminate`):
   ```
   NOTIFY_ENABLED=true
   TELEGRAM_BOT_TOKEN=8123456789:AAF...
   ```
3. **Let the bot reach you.** Telegram will not reveal a chat id until that chat
   has messaged the bot — this step cannot be skipped. Send the bot `/start`
   (personal), or add it to your team group and post any message there.
4. **Add the destination.** Manage → Notifications → *Add destination* →
   **Fetch from Telegram** lists every chat that has spoken to the bot; pick
   yours, name it, tick the events.
5. **Send test.** The result is recorded on the destination and in *Recent
   activity*.

**Gotchas**

- A **group** chat id is **negative** (`-1001234567890`); a private chat id is
  positive. The form cross-checks the sign against the chosen kind, so a group
  saved as "Personal" is rejected rather than becoming a mis-routed alert.
- **Fetch returns nothing** until someone messages the bot. If the bot has a
  **webhook** registered, `getUpdates` is unavailable (Telegram 409, they are
  mutually exclusive) — the page says so and the chat id can be pasted manually.
- **Group privacy mode** is on by default for new bots: in a group the bot only
  sees commands (`/start`) unless you turn privacy off via @BotFather. Sending
  *to* the group always works; this only affects discovery.
- A **queue worker must be running** — `send()` only queues. With no worker the
  delivery row stays `PENDING`.
- **"Telegram bot not connected" that won't go away.** The page's bot identity
  (`getMe`) is cached for 10 minutes, keyed by a fingerprint of the token — but
  **only a SUCCESS is ever cached**. A failure is re-probed on the next page
  load, so a brief network blip (Telegram from MY can be intermittently slow —
  a cold TLS handshake occasionally passes the 12s `TELEGRAM_TIMEOUT`) clears
  itself with a refresh instead of looking like a dead integration for ten
  minutes. Changing the token invalidates the entry automatically.
- **The bot token is scrubbed from every error string.** It lives in the URL
  path (`/bot<token>/method`), and Guzzle embeds the full URL in connection-
  failure messages — so `TelegramGateway::redact()` runs over everything leaving
  the class, before it reaches the log, the delivery row or the page. Locked by
  a test; never bypass it by returning a raw exception message.
- Local development needs no special setup: unlike SMS360 (IP-whitelisted),
  Telegram is reachable from a dev machine, so local behaves exactly like
  production. There is deliberately **no log-only transport**.

## Data model

**`notify_destinations`** (key model: uuid + blame + soft delete)

| Column | Type | Notes |
|---|---|---|
| `id` / `uuid` | `bigIncrements` / `uuid` unique | public identifier (`HasUuid`) |
| `transport` | `unsignedInteger` indexed | `1` telegram |
| `kind` | `unsignedInteger` indexed | `1` personal, `2` shared group |
| `label` | `string(80)` | shown in the UI |
| `address` | `string(120)` indexed | Telegram `chat_id` — **string**, negative for groups |
| `user_id` | `unsignedBigInteger` nullable, indexed | owning admin for PERSONAL; null for SHARED |
| `is_active` | `boolean` indexed | pause without losing settings |
| `verify_status` / `verify_message` | `unsignedInteger` / `string` nullable | last "Send test" result |
| `last_sent_at` | `timestamp` nullable | |
| `meta` | `json` nullable | chat type / username from discovery |
| blame + timestamps + `deleted_at` | | `RecordsBlame` + soft delete |

**`notify_subscriptions`** — `uuid`, `event_key`, `destination_id`, `is_active`,
create/update blame. **Unique `(event_key, destination_id)`.** Hard-deleted with
their destination (a routing rule has no history value of its own, and orphans
would let a restored destination silently resume firing).

**`notify_deliveries`** — `uuid`, `event_key`, `transport`, `destination_id`
(+ snapshotted `destination_label` / `address`), `status`, `title`, `body`,
`error`, `provider_ref` (Telegram `message_id`), `duration_ms`, the nullable
`subject` morph, `meta` (skip `reason`, `retryable`), `created_by`.

## Configuration

[config/notify.php](/config/notify.php):

| Key | Meaning |
|---|---|
| `enabled` | master kill switch (`NOTIFY_ENABLED`); off = every `send()` is a logged no-op |
| `queue.connection` / `.queue` | the lane deliveries ride (default `redis` / `default`) |
| `queue.retry_until_minutes` | how long transient failures keep retrying |
| `telegram.bot_token` | **`TELEGRAM_BOT_TOKEN`** — the only secret, `.env` only |
| `telegram.base_url` / `.timeout` / `.ca_bundle` | endpoint, HTTP timeout, the Laragon/Windows cURL-77 CA fix |
| `telegram.max_length` | truncation budget (3900; Telegram's own cap is 4096 **after entities parsing**) |
| `events.{key}` | the registry — `name` / `description` / `group` / `default` / `throttle` / `options` |

## GUIDELINES alignment

**Conforms**
- **§7 Key model** — `NotifyDestination extends SoftDeleteModel` + `HasUuid` + `RecordsBlame`; snake_case columns, **no schema-level FK**, `->index()` on FK + filtered columns, `_by` / `_at` suffixes, `is_` boolean prefix, `unsignedInteger` CONST columns defaulting to model constants. ✔
- **§2 Repository pattern** — every write goes through a repository inside `DB::transaction`; input filtered with `data_only()` **before** the transaction; nested arrays keyed by model name; refreshed models returned. ✔
- **§3 Constants for states** — `TRANSPORT_*` / `KIND_*` / `VERIFY_*` / `STATUS_*` / `SKIP_*` each with their metadata array; no magic values. ✔
- **§3 Thin controllers** — validation in Form Requests, explicit field-by-field mapping into the nested array, writes delegated to repositories, `flash()` + `back()`. ✔
- **§8 Validation** — dedicated Form Requests; the store request cross-checks the chat kind against the id's sign and rejects a duplicate chat. ✔
- **§13 Frontend** — `Inertia::render`, Tailwind-only Vue under `Pages/Manage/…`, shared `Modal` / `ConfirmModal` (never native `confirm()`), `devError` not `console.error`. ✔
- **§10 Security** — routes gated by `view-integrations` / `manage-integrations`; a personal destination is stamped from the session, never from input; ownership re-checked server-side on every mutation. ✔

**Deliberate deviations**
1. **`UpdateDestinationRequest` does not extend the Store request** (§8 says it *should*). The editable surface is genuinely different and much smaller — `transport` and `address` are immutable by design, since re-pointing a row would silently re-route every subscription on it. There are no Store rules worth inheriting, and inheriting its address cross-checks would only mean guarding fields that are never submitted.
2. **No DB unique index on `(transport, address)`.** The table is soft-deleted, so a real unique index would block re-adding a chat that was removed. Uniqueness is enforced in the Form Request (the `AiCredential` precedent — §2 uniqueness in the write layer).
3. **No Facade for the repositories** — follows the pattern actually in use (Media / WhatsApp / AI all inject repositories directly; the project has no `Facades`).

## Related files

**Backend — Models & registry**
- [src/Common/Notify/NotifyDestination.php](/src/Common/Notify/NotifyDestination.php) — where a message can go; `TRANSPORT_*` / `KIND_*` / `VERIFY_*`; `isManageableBy()` (the ownership rule) · `looksLikeTelegramGroup()`.
- [src/Common/Notify/NotifySubscription.php](/src/Common/Notify/NotifySubscription.php) — the `(event, destination)` pairing.
- [src/Common/Notify/NotifyDelivery.php](/src/Common/Notify/NotifyDelivery.php) — the attempt log; `STATUS_*` + `SKIP_*` reasons + `skipReason()`.
- [src/Common/Notify/NotifyEvent.php](/src/Common/Notify/NotifyEvent.php) — the read side of the registry (`exists` / `throttleSeconds` / `option` / `catalog`), and the guard against the dotted-key trap.

**Backend — Calling layer**
- [src/Common/Notify/Services/Notifier.php](/src/Common/Notify/Services/Notifier.php) — **the entry point**: `send()` (fan-out, queued) / `sendToUsers()` (addressed fan-out — the targeted users' personal destinations only) / `deliver()` (one destination, synchronous) / `destinationsFor()`.
- [src/Common/Notify/NotifyMessage.php](/src/Common/Notify/NotifyMessage.php) — the transport-agnostic message builder (`title` / `body` / `line()` / `url()` / `subject()` / `throttleScope()` / `silent()`, plus queue-safe `toArray()` / `fromArray()`).
- [src/Common/Notify/NotifyResult.php](/src/Common/Notify/NotifyResult.php) — normalized outcome; `retryable` drives the job.
- [src/Common/Notify/Services/NotifyManager.php](/src/Common/Notify/Services/NotifyManager.php) — transport resolution (the one place transports are named).
- [src/Common/Notify/Contracts/NotifyTransport.php](/src/Common/Notify/Contracts/NotifyTransport.php) — the transport contract.
- [src/Common/Notify/Transports/TelegramTransport.php](/src/Common/Notify/Transports/TelegramTransport.php) — HTML rendering, length budgeting, permanent-vs-transient classification.
- [src/Common/Notify/Services/TelegramGateway.php](/src/Common/Notify/Services/TelegramGateway.php) — the raw Bot API client (`getMe` / `getUpdates` / `sendMessage`), normalizing every outcome into one shape.
- [app/Jobs/Notify/SendNotificationJob.php](/app/Jobs/Notify/SendNotificationJob.php) — one delivery, off the request; `retryUntil()` + provider-aware release.

**Backend — Repositories**
- [src/Common/Notify/Repositories/NotifyDestinationRepository.php](/src/Common/Notify/Repositories/NotifyDestinationRepository.php) — `create` / `update` / `delete` / `recordVerification`.
- [src/Common/Notify/Repositories/NotifySubscriptionRepository.php](/src/Common/Notify/Repositories/NotifySubscriptionRepository.php) — `sync()` (registry-filtered) / `subscribeDefaults()`.
- [src/Common/Notify/Repositories/NotifyDeliveryRepository.php](/src/Common/Notify/Repositories/NotifyDeliveryRepository.php) — two-phase `start` / `complete` + one-shot `skip`; best-effort throughout.

**Backend — Controller & Form Requests**
- [app/Http/Controllers/Manage/Integrations/NotificationsController.php](/app/Http/Controllers/Manage/Integrations/NotificationsController.php) — `index` / `discover` (chat picker) / `store` / `update` / `destroy` / `subscriptions` / `test`.
- [StoreDestinationRequest.php](/app/Http/Requests/Manage/Integrations/Notify/StoreDestinationRequest.php) (kind ⇄ address cross-check + duplicate guard) · [UpdateDestinationRequest.php](/app/Http/Requests/Manage/Integrations/Notify/UpdateDestinationRequest.php) · [UpdateSubscriptionsRequest.php](/app/Http/Requests/Manage/Integrations/Notify/UpdateSubscriptionsRequest.php).

**Consumer**
- [app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php](/app/Jobs/Whatsapp/ProcessInboundWhatsAppWebhook.php) — `considerNotify()` + `notifyExcerpt()` / `notifySenderName()`. Runs **after `considerLinkContact`** (so the alert can name the sender by their CRM name — the link that resolves it may be created by this very message) and before the CTA / flow / AI chain.
- [app/Console/Commands/ReapFlowRuns.php](/app/Console/Commands/ReapFlowRuns.php) — `notifySilence()` fires **`whatsapp.flow_no_response`** when a customer never answers a flow's menu after its configured reminders (the run ends `no_response`; the alert carries the contact, flow, reminder count and a deep link to the thread, throttle-scoped per run). See [Flows](/docs/modules_handbook/manage/messages/whatsapp/flow.md) → *no-answer reminders*.
- [app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php](/app/Http/Controllers/Manage/Conversation/RecordingReviewsController.php) — `notifyOwner()` fires **`conversation.review_received`** on `store()` only (never `update()`), addressed to the reviewed recording's owner via the `admin_id` → `Admin` → `user_id` hop, throttle-scoped per review row. See the closing-rating-and-review.md conversation-review handbook for the full feature.
  - A one-off **data migration**, not a repeatable command, backfills this event's subscription onto every PERSONAL destination that predates it — [database/migrations/2026_08_06_100001_backfill_conversation_review_received_subscriptions.php](/database/migrations/2026_08_06_100001_backfill_conversation_review_received_subscriptions.php). `default => true` in the registry only pre-ticks the box for destinations created AFTER an event exists (`subscribeDefaults()` runs once, on destination creation), so adding an event with no backfill leaves every existing admin unticked and silently `no_subscribers`-skipped. **This is the one place "insert if missing" is the WRONG idempotency instinct**: unticking an event deletes the subscription row rather than flipping `is_active`, so "opted out" and "never offered the choice" are the same fact (no row) — a re-runnable backfill would silently re-subscribe an admin who deliberately opted out. Only a migration (recorded once, never replayed) can guarantee "runs exactly once, no matter how it's re-triggered."
- [app/Http/Controllers/Concerns/NotifiesActivityAssignees.php](/app/Http/Controllers/Concerns/NotifiesActivityAssignees.php) — `notifyActivityAssignees()` fires **`calendar.activity_assigned`** from `Manage\Calendar\ActivitiesController`, on `store()` for every assignee except the actor and on `update()` for only the NEWLY-ADDED assignees (diffed against the pre-write roster), addressed via the `admins.id` → `Admin` → `user_id` hop, throttle-scoped per activity at **`0`** (not a window — see the addressed-consumers section above for why a non-zero one silently dropped a second assignment on the same activity). See the [Calendar](/docs/modules_handbook/manage/calendar/readMe.md#notification--calendaractivity_assigned) handbook for the full assignment-model context this event rides on.
  - Line-for-line sibling of the `conversation.review_received` backfill above — [database/migrations/2026_08_27_100002_backfill_activity_assigned_subscriptions.php](/database/migrations/2026_08_27_100002_backfill_activity_assigned_subscriptions.php) — for the identical reason: `default => true` alone would leave every admin who registered a personal destination before this event existed permanently unticked.

**Frontend**
- [resources/js/Pages/Manage/Integrations/Notifications/Index.vue](/resources/js/Pages/Manage/Integrations/Notifications/Index.vue) — bot status + one card per destination (subscriptions inline, dirty-tracked Save) + recent activity.
- [Partials/DestinationFormModal.vue](/resources/js/Pages/Manage/Integrations/Notifications/Partials/DestinationFormModal.vue) — add/edit, with the "Fetch from Telegram" chat picker.
- Nav entry: **Setting → Notifications** — a `SettingTabs` entry
  ([resources/js/Components/SettingTabs.vue:65](/resources/js/Components/SettingTabs.vue#L65)),
  not a standalone sidebar item (GUIDELINES §15 forbids an "Others" section;
  Setting is one of the two hubs whose second level is config, per §15's
  "a section graduates to a hub" rule).

**Config, migration, routes & tests**
- [config/notify.php](/config/notify.php) · [.env.example](/.env.example) (`TELEGRAM_BOT_TOKEN`, `NOTIFY_ENABLED`, `NOTIFY_WA_THROTTLE_SECONDS`).
- [database/migrations/2026_07_26_100001_create_notify_tables.php](/database/migrations/2026_07_26_100001_create_notify_tables.php).
- Routes: `manage.integrations.notifications.*` ([routes/web.php](/routes/web.php)).
- [tests/Feature/Common/NotifyTest.php](/tests/Feature/Common/NotifyTest.php) (transport rendering + failure classification + dispatcher routing / throttling / logging) · [tests/Feature/Whatsapp/InboundNotifyTest.php](/tests/Feature/Whatsapp/InboundNotifyTest.php) (the WhatsApp consumer end-to-end through the real inbound pipeline) · [tests/Feature/Manage/Conversation/RecordingReviewNotifyTest.php](/tests/Feature/Manage/Conversation/RecordingReviewNotifyTest.php) (the review consumer, through the real `/manage/recording-reviews` endpoint — the admin/user hop, the self-review and no-owner guards, shared-destination exclusion, create-only, fail-soft, the score-only/never-comment content guard, the deep link's tab, and score `0`) · [tests/Feature/Database/ConversationReviewReceivedSubscriptionsBackfillTest.php](/tests/Feature/Database/ConversationReviewReceivedSubscriptionsBackfillTest.php) (the subscription backfill migration in isolation).

**See also:** [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) (the shared-service sibling this mirrors) · [SMS](/docs/modules_handbook/shared/sms/readMe.md) and [WhatsApp](/docs/modules_handbook/manage/messages/whatsapp/readMe.md) (customer-facing messaging — a different job) · [System Health](/docs/modules_handbook/manage/system-health/readMe.md) (where ops alerts are surfaced today).
