# Notification foundation

## Architecture

TakafulHub uses Laravel's `notifications` table for persistent in-app events.
`NotificationTypeRegistry` is the sole product-policy source: it defines the
stable key, category, supported portal, required/configurable channels and
defaults. `UserNotificationPreference` stores only a user's configurable
overrides, so new users inherit registry defaults without seed rows.

## Policy and portal scope

Mandatory account, Membership, billing/payment and Partner withdrawal channels
are always on. New Premium Lead is required in-app and optional by email.
Support, credits, affiliate/Partner commission and VastPro team updates are
configurable. VastPro team controls do not appear in TakafulHub; Membership
controls do not appear in VastPro.

The master **Optional Email Notifications** preference only suppresses optional
email. It never suppresses a required transactional email.

## Delivery

Future producers must call `NotificationDeliveryService` only after their
business transaction commits. The service asks `NotificationPreferenceService`
whether a channel is eligible, creates the database notification when allowed,
and dispatches a queued `PortalEmailNotification` for email. Delivery failures
are caught and logged; they must not roll back lead, Membership, payment,
credit, support, affiliate, Partner or VastPro business changes.

Notification payloads contain a key, concise title/message and only a trusted
internal action target. Do not add prospect contact, health, payment-provider,
wallet or attribution data to generic notification payloads or emails.

## UI and operational requirements

Agent and VastPro portals expose a bounded recent-notification bell, unread
badge, owner-scoped paginated Notification Centre and Profile preference
matrix. Read state is retained; it is never a deletion signal. The Admin and
Sites portals are intentionally outside this phase.

## Phase 4 final UX and email contract

The bell is an accessible, keyboard-dismissible compact panel. It shows no
badge at zero, caps large unread counts at `99+`, shows only the shared
five-item preview and uses a title-weight/background/dot combination for
unread state. The panel can be closed by Escape or an outside click; it is
constrained to the viewport on small screens. Its empty state is deliberately
small: **No notifications yet.** Important account and lead updates will
appear here.

The Notification Centre remains the paginated history. `All` and `Unread`
are not generic table states: the empty unread filter says **You're all caught
up.** Recent entries use friendly relative time; older entries use a readable
Malaysia-local calendar timestamp. Marking one or all notifications as read
updates the regular Inertia response state; it does not affect the email
channel or delivery history.

The preferences matrix labels required channels as **ON · Required** with a
lock rather than pretending they are editable. The Optional Email master
toggle only suppresses configurable email channels; it never rewrites the
stored per-type choices, and returning it to ON restores those choices. A
master-off state is communicated on configurable email controls. Portal policy
is registry-backed: TakafulHub does not render VastPro-only rows and VastPro
does not render Membership rows.

`PortalEmailNotification` uses one restrained transactional Laravel mail
shell: portal-aware subject/salutation (`TakafulHub` or `VastPro`), concise
event message, context-specific trusted CTA and a text fallback line directing
the recipient to the portal. It deliberately contains no marketing banners,
queue terminology, attribution or protected lead/quotation PII. In particular,
lead emails never include prospect identity, phone, email or health details.

Notifications are retained indefinitely in the current product policy. There
is no automatic pruning in this phase. If volume becomes material, a future
retention decision may archive or prune aged *read* records after a separate
data-retention review.

The configured default queue is `database` and mail defaults to `log` locally.
Production email delivery requires a real mail transport plus a supervised
`php artisan queue:work database` worker and monitoring of `failed_jobs`.
No WebSockets or polling are introduced by this phase.

## Phase 2 business-event catalogue

All business producers use `NotificationDeliveryService` through
`LifecycleNotificationService` or an equivalent post-commit producer. A
notification is informational only: a delivery or queue failure is logged and
never reverses the authoritative business transaction.

| Event | Key | Recipient | Stable dedupe identity | Safe action |
| --- | --- | --- | --- | --- |
| Standard Premium Lead assigned | `new_premium_lead` | Assigned agent | Lead assignment ID | Assigned lead detail |
| GETB Premium Lead assigned, including manual assignment | `new_getb_premium_lead` | Assigned VastPro agent | Lead assignment ID | VastPro lead detail |
| Membership activated or renewed | `membership_activated` / `membership_renewed` | Member | Membership order ID | Membership |
| Successful or failed payment | `payment_successful` / `payment_failed` | Payer | Payment attempt ID | Relevant Membership/Credits page |
| Lead Credit purchase completed | `lead_credit_purchase_completed` | Purchaser | Credit order ID | Credits |
| Admin reply to support ticket | `support_reply` | Ticket owner | Ticket ID + reply timestamp | Support ticket |
| Admin Lead Credit grant | `lead_credits_added` / `vastpro_lead_credits_added` | Recipient | Credit transaction ID | Credits |
| Affiliate/Partner commission credited | `affiliate_commission_credited` / `partner_commission_credited` | Credited user | Commission/payout ID | Affiliate or Partner workspace |
| Partner withdrawal transition | `partner_withdrawal_approved`, `partner_withdrawal_paid`, `partner_withdrawal_rejected` | Withdrawal owner | Withdrawal ID + terminal state | Partner workspace |
| VastPro approval/rank/team update | `vastpro_account_status_updated`, `vastpro_rank_updated`, `vastpro_team_assignment_updated` | VastPro agent | Registration/profile ID + state | VastPro dashboard |

Same-day lead reuse remains a real assignment record for operational history,
but deliberately emits no second lead notification. It neither creates a new
credit debit nor produces repetitive notification noise.

Membership expiry is not emitted in Phase 2 because no current authoritative
expiry transition is available. It remains explicitly deferred rather than
adding a scheduler or polling loop.

## Privacy and action safety

Lead event payloads name only the operator and never carry prospect name,
phone, email, medical/profile, quotation, wallet, payment-provider, UTM or
other attribution data. Notification actions are limited to same-origin or
configured internal portal URLs; external, protocol-relative and malformed
targets are stored as `null`.

No scheduled notification processing, WebSockets, polling, new global admin
notification centre or notification analytics is introduced by Phase 2.

## Phase 3 scheduled Membership notifications

The scheduled command `notifications:process-memberships` delegates to
`MembershipNotificationSchedulerService`. It is registered once daily at
**07:15 Asia/Kuala_Lumpur**. Production must run Laravel's scheduler, for
example:

```text
* * * * * cd /path/to/takafulhub && php artisan schedule:run >> /dev/null 2>&1
```

The authoritative Membership expiry definition is unchanged: the user's
existing latest `currentMembership()` record is eligible only when it remains
`active`; expiry is derived from its existing `ends_at` timestamp. The
scheduler does not persist `expired` or create a second Membership state
machine. Cancelled, historical/replaced records and users with a VastPro
`AgentProfile` are excluded.

Existing inactive-but-retained accounts are not silently suppressed: their
Membership reminder remains useful and follows the same current product
delivery semantics. Deleted users have no User record and therefore cannot be
selected for delivery.

Required, non-dismissible channels are sent at 7 days before expiry, 1 day
before expiry and after expiry. Dedupe keys include Membership ID and the
Malaysia-local expiry date. The 7-day reminder can catch up once at six days
remaining; the one-day reminder can catch up only while the Membership remains
active on the expiry date. Expiry is sent once when detected. Older reminders
are not backfilled.

Email remains queued. `PortalEmailNotification` has three bounded attempts
with 60, 300 and 900-second backoff. A database notification is created once;
email retries do not recreate it. Production requires a supervised worker,
for example `php artisan queue:work database`, and operational review of the
`failed_jobs` table. Failed email logging records notification key, user ID,
channel and dedupe key only. Invalid recipient email similarly leaves the
required in-app event intact and is logged without attempting mail.

No notification records are deleted in this phase. The centre remains
paginated; a future retention policy may archive or prune aged read records
only after an explicit product/data-retention decision.
