# Notification Enable/Disable System (Meeting OTP + Push Notifications)

## Context

Admins currently have no way to turn notification sending on/off. Two concrete pain points:

1. **Meeting OTP SMS** (`MeetingController::meeting_otp_send`) always fires a raw cURL SMS send with no kill switch — if the SMS gateway misbehaves or the business wants to pause OTP-gated meeting completion, there's no way to stop it short of a code deploy.
2. **Push notifications** (FCM, via `PushNotificationService` / `SendsPushNotifications` trait) fire for many unrelated event types (tasks, meetings, leave, helpdesk tickets, projects, etc.) with no per-category control — an admin can't mute just "leave" pushes while keeping "task" pushes on.

The codebase already has a **dormant, purpose-built table for this**: `notification_integrations` (`operation` string + `is_active` tinyint), plus an empty `NotificationIntegrationController` stub and a `docs/database.md` annotation ("Toggle features") — it was scaffolded but never wired up. This plan resurrects and implements it rather than building a parallel mechanism.

Scope decided with the user: build toggles for **Meeting OTP (SMS)** as a single global switch, and **push notifications** with **per-event-category granularity** (task, task_subtask, meeting, leave, helpdesk_ticket, project, general) plus one master push switch. WhatsApp (campaign channel) is explicitly **out of scope** for this pass. Access is **permission-gated**, following the existing `setting.<resource>.read` / `.override` pattern used by `WabaCredentialController`, `SmtpController`, etc.

## Design

### 1. Data model — reuse `notification_integrations`

No migration needed for the table itself (already exists: `id`, `operation`, `is_active`, timestamps). Seed it with fixed rows for these `operation` keys:

- `meeting_otp` — master switch for Meeting OTP SMS
- `push_notifications` — master switch for all push notifications
- `push_task` — task assigned/updated/deleted/completed
- `push_task_subtask` — subtask created/completed/deleted
- `push_meeting` — meeting scheduled/started/completed/cancelled (includes the `DispatchMeetingStateNotifications` reminder job)
- `push_leave` — leave submitted/approved/rejected
- `push_helpdesk_ticket` — ticket created/updated/deleted/closed
- `push_project` — project team notifications
- `push_general` — catch-all for ad-hoc `notifyUser`/`notifyUsers`/`notifySupervisors` calls that don't go through a category-specific trait method (MPIN reset, credit request, finance, payroll, lead assignment, linked devices, project chat, payment reminders, running-task reminders)

A category switch being off means "don't send that category"; `push_notifications` being off is a global override that mutes everything regardless of category state. Add a `Database\Seeders\NotificationIntegrationSeeder` that upserts these rows with `is_active = 1` by default (preserves current always-on behavior), and call it from `DatabaseSeeder`.

### 2. Central enforcement point — `NotificationSettingsService`

New `app/Services/NotificationSettingsService.php`:

- `isEnabled(string $operation): bool` — looks up `NotificationIntegration::where('operation', $operation)->value('is_active')`, defaults to `true` if no row exists (fail-open, so a missing seed never silently blocks sends). Cache the full operation→is_active map with `Cache::remember('notification_integrations', 3600, ...)`.
- `bustCache(): void` — called after any toggle update.
- `categoryForPushType(?string $type): string` — maps a push `type` string (e.g. `task_assigned`, `meeting_started`, `leave_approved`) to one of the fixed categories above via known prefixes (`task_subtask_*` checked before `task_*`; `helpdesk_ticket_*`, `meeting_*`, `leave_*`, `project`), falling back to `push_general`.
- `pushEnabledFor(?string $type): bool` — `isEnabled('push_notifications') && isEnabled(categoryForPushType($type))`.

This is the single place that knows the category-mapping rules, so `PushNotificationService` doesn't need to duplicate them.

### 3. Wire the gate into `PushNotificationService`

Edit `app/Services/PushNotificationService.php` — add the check at the top of `sendToUser`, `sendToUsers` (delegates, no extra check needed), `sendToToken`, and `sendToTopic`, since these are the actual chosen choke points that all trait methods funnel through:

```php
public function sendToUser(User $user, string $title, string $body, array $data = [], ?string $image = null, ?int $createdBy = null): bool
{
    $notification = $this->createNotificationRecord($user, $title, $body, $data, $image, $createdBy);

    if (!app(NotificationSettingsService::class)->pushEnabledFor($data['type'] ?? null)) {
        $this->markNotificationSkipped($notification, 'Disabled by notification settings.');
        return false;
    }
    ...
```

Add a small `markNotificationSkipped()` sibling to the existing `markNotificationSent`/`markNotificationFailed`, setting `status = 'skipped'` (plain string column, no migration required — confirmed via `2026_05_16_123000_create_push_notifications_table.php:23`). Apply the same guard at the top of `sendToToken` (covers direct-token calls) and `sendToTopic`. This means every existing call site (`SendsPushNotifications` trait methods, jobs like `DispatchMeetingStateNotifications`, `SendUpcomingActivityNotification`, `SendPaymentRemindersJob`, `SendRunningTasksReminder`) is covered automatically without touching each call site.

### 4. Wire the gate into Meeting OTP

Edit `app/Http/Controllers/MeetingController.php::meeting_otp_send()` (line ~593): before generating/storing the OTP and calling `send_otp()`, check `NotificationSettingsService::isEnabled('meeting_otp')`. If disabled, return the standard error envelope (`success: false`, message "Meeting OTP notifications are currently disabled", HTTP 403) without generating an OTP or mutating the meeting record — no partial state.

### 5. Admin CRUD — implement `NotificationIntegrationController`

Replace the empty stub `app/Http/Controllers/NotificationIntegrationController.php` with:

- `index(Request $request)` — permission `setting.notification_integration.read` or `.override`; returns all rows (auto-seeding any missing fixed operations on first read so the list is always complete).
- `update(Request $request)` — permission `setting.notification_integration.override`; validates `operation` (in the fixed list) + `is_active` (boolean), upserts the row, calls `NotificationSettingsService::bustCache()`, records activity via the existing `RecordsActivity` trait (matches `WabaCredentialController` pattern), returns the updated row.

Use `App\Traits\ApiResponse` and `App\Traits\RecordsActivity` like `WabaCredentialController` does, keeping response envelope consistent with the rest of the API (`docs/api.md` conventions).

### 6. Routes

In `routes/app.php`, add near the other `setting`-prefixed credential routes (around line 662):

```php
Route::prefix('notification_integration')->group(function () {
    Route::post('/fetch', [NotificationIntegrationController::class, 'index']);
    Route::post('/update', [NotificationIntegrationController::class, 'update']);
});
```

(Matches the existing `POST /fetch`, `POST /update` naming convention used by `waba_credential`, `smtp_credential`, etc., rather than introducing REST verbs inconsistent with the rest of `setting`-style endpoints.)

### 7. Permissions

Add `setting.notification_integration.read` and `setting.notification_integration.override` to wherever the permission list/seeder is defined (same place `setting.waba_credential.*` is registered) so they can be assigned to roles.

### 8. Docs to update in the same turn (per CLAUDE.md discipline)

- `docs/api.md` — document the two new `/notification_integration/*` endpoints and the new "disabled" error response from `/meeting/otp/send`.
- `docs/database.md` — update the `notification_integrations` row annotation from "Toggle features" (unimplemented) to describe the fixed `operation` keys and their meaning; note the `push_notifications.status = 'skipped'` addition.
- `docs/business-rules.md` — add a "Notification Settings" subsection under Notifications explaining master/category toggle semantics and fail-open default; note Meeting OTP send is now conditional on the `meeting_otp` toggle.
- `docs/architecture.md` — add `NotificationSettingsService` to the Services list and move `NotificationIntegrationController` into the Settings domain controller list (it's currently missing entirely, per exploration).

## Verification

1. `php artisan db:seed --class=NotificationIntegrationSeeder` — confirm all 9 operation rows created with `is_active = 1`.
2. Toggle `meeting_otp` off via `/notification_integration/update`, call `/meeting/otp/send` — expect 403 with disabled message, confirm no `otp`/`otp_expires_at` written to the meeting row.
3. Toggle it back on, resend — confirm OTP generated and `send_otp()` cURL still fires as before.
4. Toggle `push_leave` off, trigger a leave approval — confirm `PushNotification` row created with `status = 'skipped'`, no FCM call attempted (check logs / mock), while a `push_task` event in the same test still sends normally.
5. Toggle `push_notifications` (master) off — confirm all push categories are muted regardless of individual category state.
6. Confirm permission checks: a user without `setting.notification_integration.override` gets 403 on `/update` but can still `/fetch` with `.read`.
