# TagInsight Notifications: Design Spec

- **Date:** 2026-05-21
- **Author:** Yoan Yahemdi (with Claude Code)
- **Status:** Draft, pending review by @nyfen + @joel
- **Scope:** Full vision in one spec, shipped in two phases (polling v1, broadcasting v2)

---

## 1. Summary

Add a first-class in-app notification system across the TagInsight multi-repo stack. Users get a notification bell in the global nav, an inbox drawer for quick triage, a `/notifications` command center dashboard with per-source widgets that deep-link into the GTM Monitor and QA Datalayer modules, and per-user preferences (per event type, optionally per project).

Three notification sources in v1:

- GTM Monitor: new container version published
- QA Datalayer: audit finished (success terminal state)
- QA Datalayer: audit failed (job error terminal state)

Delivery channel: in-app only (no email, no Web Push, no Slack in v1).

---

## 2. Goals and non-goals

### Goals

- Persistent notification inbox surviving page reloads, scoped per user
- Real-time-feeling updates without depending on infrastructure that is not yet ready (polling-first)
- Per-event-type and per-project mute controls so the inbox stays useful as event volume grows
- Multi-client visibility with sane defaults (active client by default, opt-in cross-client view)
- A clean data model and REST surface that does not need rework when Reverb broadcasting is added in Phase 2

### Non-goals (v1)

- Email, browser push, Slack, webhook delivery
- Real-time WebSocket broadcasting (deferred to Phase 2)
- Workspace-wide / admin activity feed (only personal notifications)
- Tag-level GTM diff (only "new version published" detection; specific diff is a follow-up)
- Notifications for QA audit progress, audit start, or QA audit success-without-errors vs success-with-errors split

---

## 3. Constraints and context

- All four Laravel backends are on Laravel 10. Reverb officially requires Laravel 11. Joel has not yet wired Reverb into the deployment infrastructure. Repo-wide Laravel upgrades are pending bandwidth.
- The existing `JobStatusEvent` broadcast in `saas-qa-datalayer-api` is leftover Ably code. Treat the broadcasting layer as not-yet-available in production.
- Multi-tenancy: User to Clients is many-to-many; Client to Projects is one-to-many. All notifications must be filterable by `client_id`.
- The `commons/` package already holds shared Eloquent models (e.g., `User`, `Client`, `Project`) consumed by all four backends. The notification models will be added there.
- The shared DB is the same MySQL instance backing all four APIs (this is what makes `commons/` work today).
- Frontend communicates with core-api via `coreAxiosInstance` (`saas-front-end/src/api/core.ts`). Existing interceptor (`src/api/interceptor/index.ts`) handles auth, 401 redirects, 402 subscription modals, etc. No interceptor changes needed.
- Toast library `vue3-toastify` is already wired in `src/main.ts`. Out of scope to replace.

---

## 4. Phasing

### Phase 1 (v1, ships first)

- Data model migrations in `commons/` and core-api
- Notification emission services in `saas-tag-management-api` and `saas-qa-datalayer-api`
- REST API on core-api for inbox CRUD and preferences
- Pinia store with polling (30s baseline + on focus + on drawer open)
- Bell + drawer in global nav
- `/notifications` dashboard page (inbox + per-source widgets)
- `/notifications/preferences` page
- Daily retention sweep (auto-archive at 30d, hard-delete archived at 90d)

Phase 1 has zero hard dependency on Reverb or Laravel 11.

### Phase 2 (follow-up, after Laravel 11 upgrade + Reverb infra)

- `NotificationCreated` broadcast event dispatched by each emitter after the DB write
- Frontend Echo subscription to `private-user.{userId}.notifications`
- Pinia store flips polling off while the WebSocket is connected; falls back if it disconnects
- No data model, REST contract, or UI changes. Strict additive enhancement.

---

## 5. Architecture

### 5.1 Component map

| Repo | Role in this feature |
|---|---|
| `commons/` | Shared `Notification` and `NotificationPreference` Eloquent models + TypeScript types re-exported for the frontend |
| `saas-core-api/` | Owns the migrations, the REST API for inbox + preferences, the retention sweep command, the `SendNotificationJob` queued job (Phase 1) |
| `saas-qa-datalayer-api/` | Emits `qa.audit_finished` and `qa.audit_failed` on QA job state transitions |
| `saas-tag-management-api/` | Emits `gtm.new_version` when `GtmMonitorPoller` records a `NEW_VERSION` published version |
| `saas-tracking-plan-api/` | No changes in v1 |
| `saas-front-end/` | Pinia store, bell + drawer, dashboard page, preferences page |

### 5.2 Event flow (Phase 1)

```
[Origin API: GtmPublishedVersion saved with NEW_VERSION]
   or
[Origin API: QAJob enters terminal state (success or failure)]
        |
        v
[NotificationEmitter::<type>($subject)]
        |
        | dispatches queued job
        v
[SendNotificationJob (core-api queue)]
   1. Resolves recipients (users of project's client, minus per-type/per-project mutes)
   2. Bulk INSERT into notifications table (one row per recipient)
   3. (Phase 2 only) broadcast(new NotificationCreated($notification)) per row
        |
        v
[Frontend Pinia store polls /notifications/unread-count every 30s + on focus + on drawer open]
        |
        v
[Bell badge + inbox UI updates from REST]
```

### 5.3 Why this shape

- **DB writes from each origin API**, not HTTP calls to core-api: matches existing pattern (origin APIs already share the DB via `commons/` models), avoids inventing service-to-service auth, no hot-path HTTP dependency.
- **`SendNotificationJob` runs in core-api's queue**: keeps the recipient-resolution + mute filtering in one place rather than duplicating it in three backends. Origin APIs only know "this event happened, here is the subject". Core-api owns "who should see it, what does the row look like".
- **Phase 2 add-on lives in `SendNotificationJob`**: the only code change to add broadcasting is one line per notification row. Everything else stays.

---

## 6. Data model

### 6.1 `notifications` table

| Column | Type | Notes |
|---|---|---|
| `id` | uuid | PK |
| `user_id` | uuid FK users | Recipient |
| `client_id` | uuid FK clients | Denormalized for fast cross-client filtering |
| `project_id` | uuid FK projects, nullable | Some future event types may be client-scoped, not project-scoped |
| `type` | string enum | `gtm.new_version`, `qa.audit_finished`, `qa.audit_failed` |
| `title` | string | Denormalized; rendered in lists without join |
| `body` | string | Short summary (one or two sentences max) |
| `payload` | json | Event-specific: gtm published_version_id, qa_job_id, counts, etc. |
| `action_url` | string, nullable | Deep link for the "View" button on the row |
| `read_at` | timestamp, nullable | NULL = unread |
| `archived_at` | timestamp, nullable | NULL = in inbox |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |

**Indexes (MySQL, no partial index support):**

- `(user_id, archived_at, read_at, created_at)` for inbox queries (covers both "inbox" and "unread count" with the leftmost prefix; `created_at` last for range scan / sort)
- `(archived_at, created_at)` for the daily archive sweep
- `(user_id, type, created_at)` to support per-type filtering in the dashboard

### 6.2 `notification_preferences` table

| Column | Type | Notes |
|---|---|---|
| `id` | uuid | PK |
| `user_id` | uuid FK users | |
| `type` | string enum | Same enum as `notifications.type` |
| `project_id` | uuid FK projects, nullable | NULL = applies to all projects of all the user's clients |
| `enabled` | boolean | Default true |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |

**Constraint:** logical uniqueness on `(user_id, type, project_id)`. MySQL treats NULL as distinct in unique indexes, so a plain unique index will not block duplicate `(user_id, type, NULL)` rows. Two acceptable implementations:

- Add a generated column `project_id_key VARCHAR(36) AS (IFNULL(project_id, '00000000-0000-0000-0000-000000000000')) STORED` and put the unique index on `(user_id, type, project_id_key)`.
- Or enforce uniqueness in application code in the `savePreferences` action (read-then-upsert under a transaction). Simpler, slightly more code.

Pick the generated-column approach for safety; document the alternative for review.

**Default behavior:** absence of a row means enabled = true. A row with `enabled = false` and `project_id = NULL` means "mute this type everywhere". A row with `enabled = false` and a specific `project_id` means "mute this type for this project only".

### 6.3 Recipient resolution query

Pseudo-Eloquent:

```php
User::whereHas('clients', fn($q) => $q->where('clients.id', $project->client_id))
    ->whereDoesntHave('notificationPreferences', fn($q) =>
        $q->where('type', $eventType)
          ->where('enabled', false)
          ->where(fn($q2) =>
              $q2->whereNull('project_id')->orWhere('project_id', $project->id)
          )
    )
    ->get();
```

### 6.4 Retention

- Daily artisan command `notifications:sweep`, scheduled in `saas-core-api/app/Console/Kernel.php`. Implemented in Eloquent so it stays portable:
  - `Notification::whereNull('archived_at')->where('created_at', '<', now()->subDays(30))->update(['archived_at' => now()])`
  - `Notification::whereNotNull('archived_at')->where('archived_at', '<', now()->subDays(90))->delete()`
- Chunk these in batches of 1000 to avoid long-running locks if the table grows large.

---

## 7. Event emission

### 7.1 GTM Monitor: new container version

- **Where:** `saas-tag-management-api/app/GtmMonitor/GtmMonitorPoller.php` (the existing `pollContainer()` method). After a `GtmPublishedVersion` is saved with status `NEW_VERSION`.
- **Call site:** `NotificationEmitter::gtmNewVersion($publishedVersion)` (new service class in `saas-tag-management-api/app/Services/`).
- **Emitter responsibility:** dispatch `SendNotificationJob` onto the queue with type `gtm.new_version`, the `GtmPublishedVersion` id in payload, and a precomputed `action_url` deep-linking to the GTM Monitor view for that container.

### 7.2 QA Datalayer: audit finished

- **Where:** the state machine transition in `saas-qa-datalayer-api/app/...` that moves a `QAJob` into a successful terminal state (the same place the legacy `JobStatusEvent` fires today).
- **Call site:** `NotificationEmitter::qaAuditFinished($qaJob)`.
- **Payload:** `qa_job_id`, summary counts (passed / total / errors found) extracted from the job result.
- **`action_url`:** deep link to the QA Datalayer module's run detail page.

### 7.3 QA Datalayer: audit failed

- **Where:** the state machine transition for failed terminal states (timeout, crash, infrastructure error).
- **Call site:** `NotificationEmitter::qaAuditFailed($qaJob)`.
- **Payload:** `qa_job_id`, failure reason string.
- **Distinct from audit-finished-with-errors:** "failed" here means the job itself could not complete, not that the audit found regressions.

### 7.4 `SendNotificationJob`

Lives in core-api so it can use core-api's queue worker. Signature:

```php
new SendNotificationJob(
    string $type,
    string $clientId,
    ?string $projectId,
    string $title,
    string $body,
    array $payload,
    ?string $actionUrl,
)
```

The job:

1. Resolves recipients per section 6.3
2. Builds one `Notification` model per recipient
3. Bulk insert (one DB roundtrip per batch)
4. Phase 2: `broadcast(new NotificationCreated($notification))` per row

Origin APIs dispatch this job via Laravel's shared queue (already used cross-API today: confirm in implementation step).

---

## 8. REST API

All endpoints are on `saas-core-api`, under `/api/notifications` (mounted on the existing authenticated API group), protected by `auth:sanctum` and a new `NotificationPolicy` (users can only read or mutate their own rows).

### 8.1 Endpoints

| Method | Path | Purpose |
|---|---|---|
| GET | `/notifications` | Paginated inbox |
| GET | `/notifications/unread-count` | Polling endpoint for the bell badge |
| POST | `/notifications/{id}/read` | Mark a single notification read |
| POST | `/notifications/{id}/unread` | Toggle back to unread |
| POST | `/notifications/mark-all-read` | Bulk mark read, honors query filters |
| POST | `/notifications/{id}/archive` | Archive a single notification |
| POST | `/notifications/bulk-archive` | Archive by array of IDs |
| GET | `/notification-preferences` | Returns user's prefs grouped by type with per-project overrides |
| PUT | `/notification-preferences` | Bulk replace |

### 8.2 `GET /notifications` query params

| Param | Default | Notes |
|---|---|---|
| `client_id` | active client (from session) | Special value `all` returns across all of user's clients |
| `archived` | false | true returns archived inbox |
| `read` | omitted (all) | true / false to filter |
| `type[]` | omitted (all types) | Multi-select |
| `project_id[]` | omitted (all projects) | Multi-select |
| `cursor` | null | Cursor-based pagination on `(created_at, id)` |
| `per_page` | 20 | Capped at 100 |

### 8.3 `GET /notifications/unread-count` response

```json
{
  "count": 7,
  "by_client": {
    "client_uuid_1": 3,
    "client_uuid_2": 4
  }
}
```

Frontend uses `count` for the badge when "active client only" view, `by_client` when "all clients" is enabled.

### 8.4 `GET /notification-preferences` response

```json
{
  "preferences": [
    {
      "type": "gtm.new_version",
      "enabled": true,
      "overrides": [
        { "project_id": "uuid", "project_name": "Acme.com", "enabled": false }
      ]
    },
    {
      "type": "qa.audit_finished",
      "enabled": true,
      "overrides": []
    },
    {
      "type": "qa.audit_failed",
      "enabled": true,
      "overrides": []
    }
  ]
}
```

`PUT` accepts the same shape and replaces atomically.

---

## 9. Frontend

### 9.1 New files

| File | Purpose |
|---|---|
| `saas-front-end/src/stores/notification.ts` | Pinia store (state + actions described below) |
| `saas-front-end/src/services/notificationService.ts` | Wraps the REST calls via `coreAxiosInstance` |
| `saas-front-end/src/components/notifications/NotificationBell.vue` | Bell + badge, mounted in the global nav |
| `saas-front-end/src/components/notifications/NotificationDrawer.vue` | Right-hand drawer |
| `saas-front-end/src/components/notifications/NotificationRow.vue` | Reused in drawer and dashboard |
| `saas-front-end/src/views/notifications/NotificationDashboard.vue` | `/notifications` route |
| `saas-front-end/src/views/notifications/NotificationPreferences.vue` | `/notifications/preferences` route |
| `commons/src/types/notification.ts` | Shared TS types (Notification, NotificationType enum, NotificationPreferences) |

### 9.2 Pinia store shape

```ts
state: {
  unreadCount: number;
  unreadByClient: Record<string, number>;
  drawerItems: Notification[];
  drawerLoading: boolean;
  preferences: NotificationPreferences | null;
  pollIntervalId: number | null;
  showAllClients: boolean; // persisted in localStorage, default false
}

actions: {
  startPolling(): void;           // 30s setInterval + visibilitychange listener
  stopPolling(): void;            // called on logout
  refetchUnreadCount(): Promise<void>;
  fetchDrawer(): Promise<void>;
  fetchInbox(filters): Promise<PaginatedNotifications>;
  markRead(id): Promise<void>;
  markAllRead(filters): Promise<void>;
  archive(id): Promise<void>;
  bulkArchive(ids): Promise<void>;
  fetchPreferences(): Promise<void>;
  savePreferences(prefs): Promise<void>;
}
```

`startPolling()` is called from `App.vue`'s `onMounted` when the user is authenticated, stopped on logout / 401. Also re-runs `refetchUnreadCount()` on `document.visibilitychange` (visible) and when the drawer opens.

### 9.3 Routing

Add to `saas-front-end/src/router/`:

- `/notifications` -> `NotificationDashboard.vue` (auth required)
- `/notifications/preferences` -> `NotificationPreferences.vue` (auth required)

### 9.4 Bell + drawer placement

The bell mounts in the existing top navbar component (located adjacent to the user menu). Drawer is a global overlay, controlled by the store, not by route.

### 9.5 Dashboard layout

Three sections within `NotificationDashboard.vue`:

- **Inbox** (main column): paginated list with full filter bar (type multi-select, project multi-select, client filter with "All clients" toggle, read/unread filter, date range). Bulk actions: select-all-on-page then mark read / archive.
- **Per-source widgets** (right rail on desktop, stacked on mobile):
  - "Latest GTM version changes": last 5 `gtm.new_version` notifications, each linking to the GTM Monitor module page for that project.
  - "Recent QA audit results": last 5 `qa.audit_finished` / `qa.audit_failed`, each linking to the QA Datalayer module's run detail page.
- **Preferences shortcut card**: small link to `/notifications/preferences`.

### 9.6 Preferences page

Form-driven. Table with one row per event type:

| Event type | Enabled (global) | Per-project overrides |
|---|---|---|
| GTM new version | toggle | expandable: per-project toggles |
| QA audit finished | toggle | expandable: per-project toggles |
| QA audit failed | toggle | expandable: per-project toggles |

Per-project overrides list projects across all of the user's clients. Save sends the full updated shape via `PUT /notification-preferences`.

### 9.7 i18n

All copy goes through the existing i18n setup (`vue-i18n`). New translation keys go under the `notifications.*` namespace, mirrored in the existing locale files.

---

## 10. Cross-cutting

### 10.1 Authorization

- New `NotificationPolicy` in core-api: users can only view, update, or archive notifications where `user_id = auth()->id()`. No admin override in v1.
- Preferences are similarly self-scoped.

### 10.2 Multi-client scoping

- Default view: active client only (read from session, same source as the rest of the app).
- "Show all clients" toggle in the dashboard and the drawer header. Persisted to localStorage per user.
- Cross-client queries are protected by always filtering on `user_id` and joining through `user_clients` so users cannot read notifications for clients they no longer belong to.

### 10.3 Testing

- **Backend:** PHPUnit feature tests for each REST endpoint, unit tests for `NotificationEmitter` services in each origin API, integration test for `SendNotificationJob` (recipient resolution + mute filtering).
- **Frontend:** Vitest unit tests for the Pinia store actions (mock axios), component tests for `NotificationBell.vue` and `NotificationDrawer.vue` covering badge rendering, drawer open / close, and mark-as-read interactions.

### 10.4 Observability

- Log each `SendNotificationJob` execution with `type`, `recipient_count`, and timing.
- Log retention sweep counts daily.
- No new APM / metric pipeline in v1.

### 10.5 Failure modes

- If `SendNotificationJob` fails: standard Laravel queue retry (3 attempts). After final failure, the event is dropped (not surfaced to user). Acceptable for v1 since the underlying state (the QA job result, the GTM version row) is still persisted and visible in the source module.
- If core-api is down: bell badge stops updating; drawer fails to open with a generic error toast (existing interceptor handles this). No queued notifications are lost (they live in the DB, will appear on next successful poll).
- If user is offline: bell shows last-known count; existing offline detector in the interceptor already handles request abort + modal.

---

## 11. Migration / deployment notes

- Migrations land in `saas-core-api` (the migration runner repo).
- `commons/` model classes need to ship before any origin API can compose against them: bump `commons` version, then update composer requirements in all three Laravel backends in lockstep.
- Cron addition for `notifications:sweep` lives in `saas-core-api/app/Console/Kernel.php`.
- No infra changes for Phase 1. Phase 2 adds the Reverb dependency and requires Laravel 11 in all emitting backends.

---

## 12. Open questions for review

These do not block writing the implementation plan but should be confirmed by @nyfen or @joel before merging code:

1. **Active-client source on the backend:** confirm where to read the currently active client from in core-api requests. Today the frontend sends a header or a query param for some endpoints. Standardize for `/notifications`.
2. **Shared queue across backends:** confirm that origin APIs can dispatch a job whose class is defined in core-api. If not, the dispatch becomes a small queue-message + a core-api worker reading a dedicated queue name (functionally equivalent).
3. **Email follow-up:** is email delivery on the roadmap for v2 alongside Reverb, or strictly v3? Affects whether the preferences UI should reserve column space.
4. **Notification "view" deep links:** confirm the URL shapes for the GTM Monitor module and the QA Datalayer run-detail page. Drafted as `action_url` in the data model; actual URLs to be filled in during implementation.

---

## 13. Phase 2 addendum (for future reference)

When the Laravel 11 upgrade + Reverb infrastructure are ready:

1. Bump `laravel/framework` to `^11.0` in all four backends.
2. Add `laravel/reverb` to core-api (or the chosen central broadcaster repo).
3. Configure `BROADCAST_DRIVER=reverb` + `REVERB_HOST`, `REVERB_APP_KEY`, `REVERB_APP_SECRET` env vars in all emitting backends.
4. Create `NotificationCreated` event class in `commons/` implementing `ShouldBroadcast`, broadcasting on `new PrivateChannel("user.{$this->notification->user_id}.notifications")`.
5. Uncomment the `broadcast(new NotificationCreated($notification))` line in `SendNotificationJob`.
6. Frontend: add Echo subscription in `notification.ts` store, subscribe in `startPolling()` after auth, fall back to polling when the WebSocket disconnects.
7. No data model or REST contract changes.
